Reading a report
Every run prints a list of claims. A claim is one sentence trueup believes about your project, and a run either proves it or shows you the places where it is not true.
Here is a run where one claim did not hold.
coverage 898 files · 7524 edges · 5816 symbol · 4 namespace · 1663 external · 41 builtin · 0 unresolvedzones spec 274 · domain 39 · engine 102 · app 483 · 0 unclassified
the-analysis-reached-files okevery-import-resolves okevery-imported-name-is-exported okevery-imported-name-is-unambiguous okevery-file-belongs-to-a-zone okevery-zone-has-a-file okevery-zone-pattern-matches-a-file okevery-rule-names-a-declared-zone okevery-import-respects-its-zone-boundary 1 error src/engine/table.ts:14:9 is engine and may not reach domain: Warrant from src/domain/warrant.ts through src/shared/index.ts ──────── Code in one zone reached a symbol declared in a zone it may not reach. The edge is named by its declaring file, so a barrel in between does not excuse it.
Do this: - Move the code to a zone that may reach the target. - Or have the target expose what the caller needs through a zone the caller may reach. - Run `trueup explain <file>` to see what a file may reach.
Not the fix: widening the rule so the edge becomes legal.
no-zones-form-a-cycle ok
10 claims · 1 error · 0 warningsThe first two lines are a receipt. They exist so that a clean report cannot mean “I checked nothing”.
An edge is one name imported by one file. So this line is two edges rather than one:
import { a, b } from "./x"An import that names nothing inside a module is one edge onto the module itself. A namespace import works that way, and so does an import awaited while the program runs, because neither one says which export it will use.
A specifier the program builds as it runs names no file, so nothing about it can be checked.
Each edge is counted by what it reaches.
symbol |
a name declared in a file the run read |
namespace |
a whole module, where the import named nothing inside it |
external |
anything outside your source: a package, a stylesheet, an image |
builtin |
a node: module |
unresolved |
a specifier that pointed nowhere |
Two things on that first line are worth a look. The unresolved count should be zero, because an unresolved import is an edge no rule could judge. And the file count should look like your project. If it is far too small, the run is checking less than you think.
Each line after the receipt is one claim. It either holds, or it holds everywhere except the places listed underneath.
Every claim runs on every pass, and the whole list always prints. Fixing one thing still tells you whether everything else moved.
Under that list, past a short rule that marks where the findings end, sits the guidance for that claim. It says what the violation means and what to do about it. Where a claim reports in more than one shape, the guidance carries only the parts that match the findings you have. It is written for whoever meets the rule without having read these pages. Most of the time that is an agent, meaning a coding assistant writing code in your project.
When you only want the failures
Section titled “When you only want the failures”npx trueup --dotsOne character per claim and the counts, then nothing else unless something failed.
<marks>...........</marks><coverage>3 files, 0 edges, 0 unresolved</coverage>
<summary>11 claims, 0 errors, 0 warnings</summary>. |
a claim that holds |
! |
a claim whose only findings are in the baseline |
E |
a claim with real errors |
Only the claims with real errors are explained, and only their errors. Warnings stay in the tally.
<marks>....!...E..</marks><coverage>5 files, 2 edges, 0 unresolved</coverage><baseline>2 known, 0 stale</baseline>
<claim name="every-import-respects-its-zone-boundary" errors="1"><findings>* src/engine/table.ts:1:10 is engine and may not reach domain: Warrant from src/domain/warrant.ts</findings><guidance>Code in one zone reached a symbol declared in a zone it may not reach. …</guidance></claim>
<summary>11 claims, 1 error, 2 warnings</summary>The counts stay at the top even here. A short report still has to show you that something was checked.
This is the mode to hand to an agent that checks after every change it makes. A green run costs it four lines instead of twenty. Each part sits in a tag of its own, so the agent can tell where the findings end and the guidance starts.
When you want one thing to fix
Section titled “When you want one thing to fix”npx trueup --nextOne problem, then stop.
<summary>problem 1 of 1, 11 claims, 3 errors, 0 warnings</summary>
<claim name="no-declaration-is-written-twice" errors="3"><findings>* src/claims/isolation.ts:3:7 declares posix, which is written the same way in …* src/guard/protected.ts:3:7 declares posix, which is written the same way in …* src/zones/assign.ts:3:7 declares toPosix, which is written the same way in …</findings><guidance>The same declaration was written more than once, in files that could have shared it. …</guidance></claim>A problem is not the same as a finding. Findings that share one cause arrive together, because you cannot fix one copy of a duplicated declaration without seeing the others. Everything else is one finding, one problem.
Problems arrive in the order the claims run, so the checks about the analysis itself come first. While an import fails to resolve, every other answer is drawn from a graph with a hole in it.
Run it, fix what it shows, run it again. When the errors are gone it tells you there is nothing left to fix.
When something else has to read it
Section titled “When something else has to read it”npx trueup --jsonThe whole report, claims in the order they ran, each with its findings. A claim that found nothing carries no guidance, because the remedy is written for a violation that is not there.
{ "claims": [ { "claim": "every-imported-name-is-unambiguous", "guidance": "Two star re-exports supply the same name, so which one a consumer gets is undefined and no rule can say where it came from.\n\nDo this:\n- Export it from one place.\n- Or re-export it by name rather than through a star.", "findings": [ { "severity": "error", "message": "imports price from ../pricing/index.js, which re-exports it from more than one module", "file": "/home/you/shop/src/checkout/total.ts", "start": 9 } ] } ], "coverage": { "files": 5, "edges": 2, "symbolEdges": 0, "externalEdges": 0, "builtinEdges": 0, "namespaceEdges": 0, "unresolvedImports": 0, "filesByZone": { "pricing": 3, "checkout": 2 }, "unclassifiedFiles": 0 }}A claim that holds keeps its entry, with an empty list of findings. The shape does not change between a green run and a red one. The file path is absolute, and the start is a character offset into that file. The exit code is the same as in any other mode.
On a GitLab merge request
Section titled “On a GitLab merge request”npx trueup --gitlabThe same findings in GitLab’s Code Quality format, which puts each one on its line in the merge request diff. A red pipeline then says what broke without anyone opening the job log.
[ { "description": "is web/pages and may not reach lib/domain: isSettled from packages/lib/src/domain/order.ts — Code in one zone reached a symbol declared in a zone it may not reach. The edge is named by its declaring file, so a barrel in between does not excuse it.\n\nlib has an api zone, so every other package reaches it through lib/api and nowhere else. That closes lib/domain.\n\nDo this:\n- Move the code to a zone that may reach the target. …", "check_name": "every-import-respects-its-zone-boundary", "fingerprint": "a6336074136ec154dc9fcaa5bf72dacab993d46e2b1fe6d2ad64dd52a09be576", "severity": "major", "location": { "path": "apps/web/src/cart.ts", "lines": { "begin": 2 } } }]The guidance rides along in the description field, because that is the only field GitLab shows.
Severity follows the baseline, which is the recorded list of problems you have agreed to live with for now. A new violation is major. One the baseline already accepted is minor, so it stays visible in the widget without competing with what this branch broke. Nothing is ever a blocker, because the exit code already fails the pipeline.
architecture: script: npx trueup --gitlab > gl-code-quality-report.json artifacts: when: always reports: codequality: gl-code-quality-report.jsonThe part to get right is the line that keeps the artifact on every run. Without it, the artifact is dropped on exactly the runs that had something to say.
Some findings name no file at all: an empty zone, or a pattern nothing matches. Those are placed on the config they came from. The fingerprint is a hash of the claim, the path and the message, with no line number in it. Reformatting a file does not bring back a finding GitLab had already seen.
On a GitHub pull request
Section titled “On a GitHub pull request”npx trueup --githubThe same findings as SARIF, which is the format GitHub code scanning reads. Upload that file and every finding becomes an alert sitting on the line that caused it, in the pull request diff and in the repository’s security tab.
{ "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", "runs": [ { "tool": { "driver": { "name": "trueup", "informationUri": "https://vismek-solutions.github.io/trueup/", "rules": [ { "id": "every-import-respects-its-zone-boundary", "shortDescription": { "text": "Code in one zone reached a symbol declared in a zone it may not reach. The edge is named by its declaring file, so a barrel in between does not excuse it." }, "fullDescription": { "text": "Code in one zone reached a symbol declared in a zone it may not reach. …" }, "help": { "text": "Code in one zone reached a symbol declared in a zone it may not reach. …\n\nDo this:\n- Move the code to a zone that may reach the target. …" } } ] } }, "results": [ { "ruleId": "every-import-respects-its-zone-boundary", "ruleIndex": 0, "level": "error", "message": { "text": "is web and may not reach lib/domain: isSettled from packages/lib/src/domain/order.ts through packages/lib/src/api/index.ts" }, "partialFingerprints": { "trueupFinding/v1": "739863bc6908c780b6a1720007a9b289f24f470b5b727614c348efba421a14f0" }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "apps/web/src/cart.ts" }, "region": { "startLine": 1, "startColumn": 10 } } } ] } ] } ]}The remedy is split across two fields because GitHub reads them in different places. The opening of the guidance becomes the short description, which is the part that fits on one row of the alert list. The whole remedy becomes the help text, which is what opening an alert shows.
Severity follows the baseline, which is the recorded list of problems you have agreed to live with for now. A new violation is an error. One the baseline already accepted is a warning, so it stays visible without competing with what this branch broke.
permissions: contents: read security-events: write
jobs: architecture: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: npx trueup --github > trueup.sarif
- uses: github/codeql-action/upload-sarif@v4 if: always() with: sarif_file: trueup.sarifTwo lines there matter more than the rest. The write permission is what lets the upload through, and the always condition is what keeps the upload on a run that failed. Without it the report is dropped on exactly the runs that had something to say.
A clean run still writes a file, holding a run with no results. Uploading that is how GitHub learns the alerts are gone, so it is worth running this on every branch rather than only where you expect a failure.
Some findings name no file at all: an empty zone, or a pattern nothing matches. Those are placed on the config they came from. The fingerprint is a hash of the claim, the path and the message, with no line number in it. Reformatting a file does not open a second alert for a problem GitHub is already tracking.
A flag trueup does not know
Section titled “A flag trueup does not know”$ npx trueup --claim=no-directory-holds-too-many-filesunrecognised: --claim=no-directory-holds-too-many-files
trueup — checks that the code matches the architecture its rulebook describes
usage: trueup [options] trueup <command> [arguments]The full list of options and commands follows that, so a mistyped flag also shows you the right one. You can ask for the same list at any time:
npx trueup --helpSuppose trueup had quietly ignored that flag and run the default check instead. It would tell you the project is clean, having done something other than what you asked. That is the same silence the completeness claims exist to prevent, so it gets its own exit code rather than being folded into a failed run.
Two flags that each ask for a different report are refused the same way, rather than one of them winning quietly:
$ npx trueup --dots --json--dots and --json answer different questions, so give one of them
trueup — checks that the code matches the architecture its rulebook describes
usage: trueup [options] trueup <command> [arguments]Exit codes
Section titled “Exit codes”| code | meaning |
|---|---|
0 |
clean |
1 |
errors |
2 |
the baseline holds entries nothing reports any more |
3 |
no rulebook found |
4 |
an argument it does not know |
5 |
the rulebook would not load |
A full check on a 900-file monorepo takes about a tenth of a second. The guard is the part that inspects an edit before it is saved, and it takes about the same, including the time to start the process.
Identifiers are only read when a seam rule or a rule of your own asks for them. A run without either never builds a syntax tree.