fallow audit
Get one pass, warn, or fail verdict for the files a PR changes, from dead code, complexity, duplication, and styling checks. CLI reference for fallow audit, the quality gate for PRs and AI-generated code review.
fallow audit tells you if a PR passes your quality gate. It checks the changed files for dead code, complexity, duplication, and styling, and returns a pass, warn, or fail verdict from the severity of the issues it finds.
Audit checks only the changed files, which keeps the signal-to-noise ratio high. By default, only issues that the current PR or commit introduces fail the gate. Inherited findings in touched files show as context.
Audit also runs diff-risk rules. These rules report risky facts about the change itself, such as a skipped test or a new suppression comment.
Dependency findings (unused, misplaced, type-only or test-only dependencies, overrides and catalog entries) are in the audit only when the change touches the package.json or catalog file that declares them. Unlisted dependencies follow their import sites. fallow dead-code reports all of them.
fallow audit is the gate for pull requests. For the first full-repo cleanup of an existing codebase, start with Adopt Fallow in an existing repo. That guide walks through fallow, fallow dead-code, fallow dupes, and fallow health, and hands the actual cleanup to an AI agent before you turn on audit in CI.
fallow audit
Options
Base ref
| Flag | Description |
|---|---|
--base <REF> | Git ref to compare against (for example main, HEAD~5, or a commit SHA). Alias for --changed-since. Default: the git merge-base with the branch's upstream or the remote default (origin/main). To pin the base without a flag, set FALLOW_AUDIT_BASE. |
Diff scoping
| Flag | Description |
|---|---|
--diff-file <PATH> | Path to a pre-computed unified diff (for example git diff --unified=0 main...HEAD > /tmp/pr.diff). Fallow keeps only the source-anchored findings on lines inside an added hunk. Pass - for stdin (gh pr diff | fallow audit --diff-file -) or use the --diff-stdin alias. The bundled GitHub Action and GitLab CI template compute the diff and pass it for you. You need this flag only for non-PR pipelines or local runs. Global flags documents the filtering rules and the FALLOW_DIFF_FILE fallback. |
Gate
| Flag | Description |
|---|---|
--gate new-only | Default. Compare against the base ref and fail only on findings that the current changeset introduces. JSON reports inherited findings in attribution and marks them introduced: false. |
--gate all | Strict mode. Fail on every finding in changed files, including findings inherited from the base ref. Skips the extra base-snapshot attribution pass, so the run is faster. |
You can set the gate in three places. All three accept the same values (new-only, all). Precedence: the CLI flag or the MCP gate parameter, then the config, then the default (new-only).
fallow audit --gate allDocs-only-diff fast path
Under --gate new-only, audit normally runs every analysis twice: once on the current tree, and once on a temporary worktree at the base ref. The second run tells audit if each finding is introduced or inherited.
audit skips the second run when every changed file is one of these:
- a non-behavioral doc (
.md,.markdown,.txt,.rst,.adoc) - a
.ts/.tsx/.js/.jsx/.mjs/.mts/.cjs/.ctssource file that is token-equivalent at the base ref (a comment-only or whitespace-only edit)
In that case, audit reuses the findings of the current run as the base snapshot and classifies all of them as inherited. Docs-only PRs and formatter sweeps then take about one analysis pass instead of two.
To check if the fast path fired, run fallow audit --base main --gate new-only --performance. The JSON output has base_snapshot_skipped: true|false, and the human output prints the same value on stderr. The fast path turns on automatically when every entry in the diff qualifies. No flag turns it on or off.
New-only duplication demotion
Under --gate new-only, clone-group attribution has one exception. When no instance of an introduced clone group overlaps an added line in the diff of the run, audit demotes the group to inherited. This happens when a change removes duplication elsewhere and so gives a remaining clone group a new structural key. The group is new to the report, but the change wrote no duplicated line, so the group does not fail the gate.
You can see every demotion. The stderr note about findings that the gate excluded gets an extra line with the demoted count and the diff that decided it:
audit gate excluded 3 inherited findings (run with --gate all to enforce)
of which 1 clone group was reclassified as pre-existing: no duplicated line was added by this change (diff source: merge-base worktree diff vs main)
With --explain, the duplication section adds one line per demoted group, with the same cap as the clone listing. Each line shows:
- the group's
dup:<fingerprint>id (matching the listing andfallow dupes --trace) - its instance locations
- the deciding rule
A closing line names the diff source. In JSON, each demoted entry in duplication.clone_groups[] has a demotion_reason field, and the attribution block counts demotions in duplication_demoted. See Key fields.
The decision uses the same diff precedence as diff scoping. A diff from --diff-file, --diff-stdin, or FALLOW_DIFF_FILE wins over the merge-base worktree diff. A supplied diff with a narrower base than the merge base can demote duplication that the branch really introduced. An example is a diff of only the last commit on a branch with several commits. Compute a supplied diff from the same base that the audit compares against. When no diff is available, audit skips this check and every introduced clone group still gates.
Output
| Flag | Description |
|---|---|
-f, --format <FORMAT> | Output format: human (default), json, sarif, compact, markdown, codeclimate, gitlab-codequality, pr-comment-github, pr-comment-gitlab, review-github, review-gitlab, github-annotations, github-summary. badge exits 2. |
-q, --quiet | Suppress progress and status output on stderr |
--explain | Add metric explanations to JSON output (_meta objects with docs links). In human output, also list each clone group that the new-only gate demoted, with the deciding diff source. See New-only duplication demotion. |
Review brief
| Flag | Description |
|---|---|
--brief | Render the deterministic review brief instead of the gating audit report. The brief answers "where do I look?", not "will CI block this?". It runs the same analysis and always exits 0. Works with every --format. fallow review implies this flag. See Review brief and decision surface. |
--max-decisions <N> | Maximum number of structural decisions in the decision surface of the brief. Default 4. Fallow clamps the value to a 3 to 5 band. Used only on the brief path. |
--walkthrough-guide | Print the walkthrough guide for an agent: the current digest (brief + decision surface), the review direction, the JSON schema that the agent must return, and a deterministic graph-snapshot hash pinned into the digest. Implies the brief. Always exits 0. See Agent walkthrough loop. |
--walkthrough-file <PATH> | Read the judgment JSON of an agent and validate it against the live graph. See Agent walkthrough loop for the accepted and rejected outcomes. Implies the brief. Always exits 0. |
--walkthrough | Render the walkthrough guide as a staged tour in the terminal (Stage 1 load-bearing, Stage 2 mechanical). With --format markdown, render text that you can paste into a PR. --format json --walkthrough prints the same agent-contract JSON as --walkthrough-guide. Implies the brief. Always exits 0. |
--mark-viewed <FILE> | Record one or more changed files as viewed in the local walkthrough ledger, then render the tour. Files that are already viewed (and still current) collapse into the Cleared panel. Repeatable. When the tree moves, the render ignores the stale marks but never deletes them. Used only on the --walkthrough path. |
--show-cleared | Expand the Cleared panel in the human and markdown walkthrough tour. The panel then lists each de-prioritized and already-viewed file, instead of a one-line summary. Used only on the --walkthrough path. |
--show-deprioritized | Expand the de-prioritized units in the weighted focus map of the brief. --format json always includes the deprioritized list. This flag only expands the human focus render, which collapses the list by default. Used only on the brief path. |
Scoping
| Flag | Description |
|---|---|
[PATH] | Report only findings in this file or directory. Fallow still builds the full project graph. |
-w, --workspace <NAME> | Scope output to one or more workspace packages. Comma-separated values, globs, and !-prefixed negation are supported. |
--production | Exclude test, story, and dev files (applies to dead-code, health, and dupes) |
--production-dead-code | Production mode for dead-code only. See global flags. |
--production-health | Production mode for health only. |
--production-dupes | Production mode for duplication only. |
--only <dead-code,architecture,dupes,health> | Run only these sub-analyses, with the values of bare fallow --only. Replaces the audit.deadCode, audit.health and audit.dupes selection. architecture keeps only the import cycles, boundary violations and policy violations of the dead-code sub-analysis. |
--skip <dead-code,architecture,dupes,health> | Skip these sub-analyses, on top of the audit.* selection. A skipped sub-analysis is skipped on the head and on the base snapshot, so it leaves the verdict, summary and attribution. Its JSON block is absent and summary.dead_code_skipped, complexity_skipped or duplication_skipped is true, so a 0 count does not read as a clean result. Styling findings come from the health sub-analysis. architecture drops the cycles, boundary violations and policy violations. A baseline flag for a skipped sub-analysis exits 2. The review brief (--brief, fallow review) keeps every sub-analysis. |
--no-css | Turn off audit styling analytics completely. |
--css-deep | Force deep styling analytics on when config turns it off. Deep CSS is on by default in audit. Fallow narrows project-wide styling reachability back to changed anchors, and JSON can include complexity.css_analytics and first-class complexity.styling_findings. |
--no-css-deep | Keep local styling analytics on, but skip project-wide styling reachability. |
Thresholds
| Flag | Description |
|---|---|
--max-crap <N> | Maximum CRAP score before the audit fails (default: 30.0, configurable via health.maxCrap). A function at or above this score adds to the fail verdict, together with dead-code and complexity findings. Pair with --coverage for accurate per-function CRAP. Without coverage data, fallow estimates coverage from the module graph. |
--coverage <PATH> | Path to Istanbul coverage data (coverage-final.json) or raw V8 coverage (a NODE_V8_COVERAGE directory, or one V8 coverage JSON file) for accurate per-function CRAP scores in the health sub-analysis. You can also set it with FALLOW_COVERAGE, then health.coverage (the same chain as fallow health). Format and semantics match fallow health --coverage. Without any of these, fallow auto-detects coverage/coverage-final.json or falls back to the static-estimate model. The head pass and the base attribution pass both use the resolved map. A configured file that does not exist exits 2, as in fallow health. Relative paths resolve against --root. |
--coverage-root <PATH> | Absolute prefix to strip from file paths in coverage data before fallow prepends the project root. You can also set it with FALLOW_COVERAGE_ROOT, then health.coverageRoot. A relative health.coverageRoot exits 2, as in fallow health. Use it when CI or Docker generated the coverage under a different checkout root (for example /home/runner/work/myapp on GitHub Actions). |
Runtime coverage
fallow audit accepts these two runtime coverage flags. For the coverage formats, the capture steps and the hot-path rules, see fallow coverage.
| Flag | Description |
|---|---|
--runtime-coverage <PATH> | Merge runtime coverage into the health sub-analysis, so you do not need a second fallow health run in CI. Accepts a V8 coverage directory, a single V8 JSON file, or an Istanbul coverage map JSON file. The hot-path-touched verdict is informational and does not change the exit code. See fallow health --runtime-coverage. A single local capture is free. Continuous or multi-capture monitoring needs a license. See fallow license. |
--min-invocations-hot <N> | Threshold for hot-path classification when --runtime-coverage is active (default: 100). |
Baselines
| Flag | Description |
|---|---|
--dead-code-baseline <PATH> | Baseline file produced by fallow dead-code --save-baseline. Dead-code issues in the baseline do not count toward the verdict. |
--health-baseline <PATH> | Baseline file produced by fallow health --save-baseline. Complexity findings in the baseline do not count toward the verdict. |
--dupes-baseline <PATH> | Baseline file produced by fallow dupes --save-baseline. Clone groups in the baseline do not count toward the verdict. |
audit rejects the global --baseline / --save-baseline flags (exit 2), because audit runs three analyses with incompatible baseline formats. Use the per-analysis flags above, or set defaults in .fallowrc.json:
{
"audit": {
"deadCodeBaseline": "fallow-baselines/dead-code.json",
"healthBaseline": "fallow-baselines/health.json",
"dupesBaseline": "fallow-baselines/dupes.json"
}
}
Store committed baselines outside .fallow/. fallow init adds that directory to .gitignore for the machine-local cache.
CLI flags override config. An unset baseline has no effect. You can set any subset (for example dead-code only).
Audit cannot judge a baseline
An audit analyzes only the files that changed against its base. A whole-project baseline therefore matches less of the run. The narrow scope causes this, not a stale baseline. An audit loads the baselines that you pass, but it does not judge them.
Each loaded baseline has its own baseline_staleness object in the envelope:
dead_code.baseline_stalenessduplication.baseline_stalenesscomplexity.summary.baseline_staleness
All three report change_scoped: true. Their scope_reasons names what narrowed that sub-pass: changed-since for dead_code, and changed-files for duplication and complexity. All three report gate_trips: false. A section that loaded a baseline it cannot read as its own reports true. Do not build a gate on these values.
To judge a baseline, run the command that owns it over the whole project. The section label is not the command name: duplication belongs to fallow dupes, complexity to fallow health, and dead_code to fallow dead-code.
An audit that loaded a baseline also has gate_outcomes["stale-baseline"] set to {"status": "skipped", "enforced": false}. There is one entry for any number of loaded baselines. The entry puts this fact on the gate outcomes line, which the pull-request comment and the merge-request note render. The entry also stays skipped for a baseline of another kind. See CI for what the shipped integrations report per baseline.
An audit checks the kind of every baseline that it loads, and reports every mismatch. Each mismatch prints a warning on stderr that names the flag with the path: --dead-code-baseline, --dupes-baseline or --health-baseline. That section has baseline_staleness.unrecognised_format: true. Next to it, saved_by names the command that saved the file: dead-code, dupes, or health. The saved_by field is absent for an empty file, for a baseline saved before files named their writer, and for a writer that this version does not know. To fix it, point that flag at a baseline from the command that owns it.
audit accepts --fail-on-stale-baseline, but in audit this gate never runs. A run that sets the flag with a baseline prints one warning on stderr, which names the commands that can run the gate.
--fail-on-baseline-growth does run in audit. It does not judge the findings of the run. It compares each file of --dead-code-baseline, --dupes-baseline and --health-baseline with the same file at the base ref, so a scoped run can judge it. See Baseline growth gate.
Verdict
| Verdict | Exit code | When | What to do |
|---|---|---|---|
| pass | 0 | No introduced issues in changed files (new-only) or no issues at all (all) | Ship it. |
| warn | 0 | Issues found, all warn-severity | CI passes. Consider a fix before the issues become errors. |
| fail | 1 | Error-severity issues found | Fix the reported issues before merging. |
| error | 2 | Runtime error (invalid ref, not a git repo, config error) | Check the error message. In JSON format, audit prints {"error": true, "message": "...", "exit_code": 2}. |
Dead code issues use the severity from your rules configuration (error/warn/off). Diff-risk rules use their rule severity in the same way. Complexity findings above the configured thresholds use the complexity rules. They default to error. With warn, a complexity finding gives a warn verdict. The thresholds come from your fallow health config (defaults: cyclomatic 20, cognitive 15). Duplication is a warning unless you configure a --threshold. With the default new-only gate, the report can show inherited error-severity findings while the audit exits 0. The audit ignores --fail-on-issues and keeps its own verdict.
The parse error gate (--fail-on-parse-error or failOnParseError) checks every file that the audit parsed, not only the changed files. The verdict does not include this gate. Read gate_outcomes for the exit reason.
Inline suppression comments (// fallow-ignore-next-line) work in audit. They suppress findings in changed files in the same way as in fallow dead-code.
JSON contract: which fields are severity-aware
Audit reports three counts that look alike but answer different questions. A CI integration or other tool that reads the output must gate on the right one:
| Field | What it counts | Severity-aware? |
|---|---|---|
verdict | Overall outcome (pass / warn / fail). Uses the per-rule severity from .fallowrc.json | Yes |
attribution.dead_code_introduced, attribution.complexity_introduced, attribution.duplication_introduced, attribution.styling_introduced | Findings that the changeset introduced under gate: new-only, at any severity | No |
attribution.diff_risk_introduced | All diff-risk findings, under both gates (new-only and all), at any severity. The change introduced each diff-risk finding | No |
summary.dead_code_issues, summary.complexity_findings, summary.duplication_clone_groups | All findings in changed files (any gate), at any severity | No |
Per-finding introduced: true | false | Whether the changeset introduced this finding | No (severity is per-rule, not per-finding) |
Rule of thumb for CI gating: branch on verdict == "fail", or check the exit code, which follows the verdict. Do not count introduced findings. A count brings back the bug that command: audit was built to fix: a project with unused-exports: warn would fail CI on every PR that adds a warn-tier finding, although the verdict correctly says warn ("do not fail"). The official GitHub Action and GitLab CI template already gate on the verdict. Third-party wrappers should follow the same contract.
Rule of thumb for AI agents: read verdict first to see if the run passed or failed. Then read attribution for the new and inherited counts, and walk the finding arrays per category for the details. To keep only the findings that the current PR introduced, filter on introduced: true.
Examples
# Auto-detect base branch
fallow audit
# Explicit base ref
fallow audit --base main
# Audit last 3 commits
fallow audit --base HEAD~3
# Strict mode: fail on inherited findings too
fallow audit --gate allExample output
Audit scope: 8 changed files vs main (d4a2f91..HEAD)
✓ No issues in 8 changed files (1.18s)
Audit scope: 8 changed files vs main (d4a2f91..HEAD)
■ dead code 0 · complexity 2 (warn, max cyclomatic: 12) · duplication 0
✓ dead code: 0 issues · complexity: 2 findings (warn) · 8 changed files (1.18s)
The ■ summary line appears only on warn verdicts, before any detail sections. --quiet hides it.
Audit scope: 12 changed files vs main (d4a2f91..HEAD)
── Dead Code ──────────────────────────────────────
● Unused exports (2)
src/components/Button.tsx (2)
:42 OldButton
:67 deprecatedHelper
Exported symbols with no known consumers
https://fallow.tools/docs/explanations/dead-code/#unused-exports
── Duplication ────────────────────────────────────
● Duplicates (1 clone group)
23 lines 2 instances
src/utils/helpers.ts:10-32
src/legacy/compat.ts:5-27
── Complexity ─────────────────────────────────────
● High complexity functions (1)
src/parser.ts
:45 parseExpression
28 cyclomatic 32 cognitive 80 lines
Functions exceeding cyclomatic, cognitive, or CRAP thresholds; ! marks the
dimension that breached
https://fallow.tools/docs/explanations/health/#complexity-metrics
✗ dead code: 2 issues · complexity: 1 finding · duplication: 1 clone group · 12 changed files (2.14s)
How it works
-
Resolve base ref: audit uses
--baseif you set it, thenFALLOW_AUDIT_BASE. Otherwise it auto-detects thegit merge-base(fork point) with the branch's upstream (@{upstream}) or the remote default (origin/HEAD→origin/main→origin/master). Because audit uses the merge-base and not a bare branch name, a feature branch compares against the point where it forked, not against a localmainthat can be stale. Repositories with nooriginremote fall back to the localmain/masterbranch. If audit cannot find a base, it stops with an error. -
Find changed files: runs
git diff --name-only <base>...HEAD(three-dot diff, showing changes since the merge base). -
Run three analyses scoped to changed files: dead code, complexity, duplication.
-
Compute attribution: with
new-only, audit runs the same analyses at the base ref and compares structural issue keys. A dead-code key is the line-free canonical key of the finding: the rule, the root-relative path and the names, the same input as thefinding_id. A line shift above a finding therefore keeps it inherited. The keys compare by count, so a new second finding with the key of an inherited finding is introduced. An unlisted dependency is keyed by the package, so a new import site of a package that the base already reports stays inherited.A complexity finding matches its base finding by the root-relative path and the function name. The line and the exceeded category do not affect the match, and a renamed file keeps its match. The finding is introduced when no base finding matches it, or when a metric that it exceeds has a higher value than in the base finding. An unchanged or lower metric keeps the finding inherited, also when the exceeded category changes (for example from
bothtocyclomatic). When a file has more than one finding with the same function name, each base finding matches at most one head finding. Findings with unchanged values match first, and the others match in line order.A merge of the default branch brings in code that the default branch already has. When audit auto-detects the base, it also runs the analyses at the merged default-branch commits:
MERGE_HEADof a merge in progress, and the merge-base with the default branch after a merge commit. A finding that one of these commits already has is inherited, so the gate does not block the merge commit or the push after it. Code from a merged branch that the default branch does not contain stays introduced. With--baseorFALLOW_AUDIT_BASE, audit compares with that one base only. -
Compute verdict:
new-onlygates only introduced findings.allgates every finding in changed files and skips the extra base-snapshot analysis.
Diff-risk rules
The diff-risk rules report a fact about the change itself. Audit compares the base text and the head text of each changed file. The base text comes from the merge base of the base ref and HEAD. Each finding is new in the change, so each finding has introduced: true. Only fallow audit and the MCP audit tool run these rules.
A finding is not a proof of a defect. It shows a change that a reviewer must look at, for example a skipped test or a new suppression comment.
| Rule | Default | What it reports |
|---|---|---|
weakened-tests | warn | A change that makes the tests or a coverage threshold weaker |
added-suppression | warn | A new lint, type-check, coverage, or Fallow suppression comment |
test-only-branch | warn | A new test-environment check in source code |
diverged-clone-sibling | off | An edit to one copy of duplicated code, while the other copies did not change |
added-dependency | off | A new dependency in a package.json |
The rule severity sets the effect on the verdict:
- An
errorfinding fails the audit. - A
warnfinding changes apassverdict towarn. - An
offrule does not run.
Each finding has only suppression actions. Fallow never applies a fix for a diff-risk finding. A file that cannot hold a comment, such as a package.json or a JSON config file, gets only a suppress-in-config action. The same is true for a deleted file. A rename source is skipped. A rename target reads its base text at the old path.
Weakened tests
The change makes the tests weaker. The kind field tells how:
kind | What changed |
|---|---|
skip-only | A new skip or focus marker: .skip, .only, xit, xtest, xdescribe, fit, or fdescribe |
removed-tests | A test file has fewer test cases, and the total over all changed test files also dropped |
assertion-drop | A test file has fewer expect, assert, or should calls, and no test case was removed |
coverage-threshold | A coverage or quality threshold is less strict |
The test kinds read JavaScript-family test files only, such as .test.ts or .spec.js files. A test file in another language, such as a Rust integration test, can hold it.skip text in a string, so the rule does not read it.
A test or an assertion that moved to another changed test file does not report, because the total did not drop.
These drops do not report either:
- The lost test lines name code that the same change removed, such as a deleted function, error code, or fixture directory. The tests went with that code. A name of a deleted file that a changed file still holds, a name that a deleted file only imports, a directory that still exists, and a reserved word such as
importdo not count. A deleted directory or file name matches only a whole path segment. - An assertion drop where assertions are less than half of the code lines that the test file lost. A test rewrite that moves setup checks into a shared helper does not report.
coverage-threshold reads test-runner, coverage, and Fallow config files, such as vitest.config.*, jest.config.*, .nycrc, .c8rc, package.json, and .fallowrc.json. A lower lines, branches, functions, statements, minScore, or minCoverage value reports. A higher maxCrap, maxCyclomatic, or maxCognitive value reports too.
| Severity | Default |
|---|---|
| Warn | Yes |
Example: a change turns it('charges tax', ...) into it.skip('charges tax', ...), or lowers coverage.thresholds.lines from 90 to 70.
When to act: Always look at the finding before you merge. If the weaker test or threshold is intended, suppress the finding with // fallow-ignore-next-line weakened-tests. Use // fallow-ignore-file weakened-tests for a finding about the whole file.
Added suppressions
The change adds a comment line with a suppression. Audit reports these markers: eslint-disable, oxlint-disable, biome-ignore, deno-lint-ignore, @ts-ignore, @ts-expect-error, @ts-nocheck, istanbul ignore, c8 ignore, v8 ignore, and fallow-ignore.
- A moved comment is not added, so it does not report.
- A marker inside a string literal does not report.
- A
fallow-ignorecomment that names only diff-risk rules does not report. - A file below a fixture or generated directory (
fixture,fixtures,__fixtures__,__snapshots__,generated,__generated__) does not report, because a tool writes it or a test reads it as data. The match uses the directory name only. - Only a suppression that names
added-suppressionhides this finding. A new blanketfallow-ignorecomment cannot hide itself.
| Severity | Default |
|---|---|
| Warn | Yes |
Example: a change adds // @ts-expect-error above a call, where a correct type is the better fix.
When to act: Make sure that the suppression is necessary. If it is, keep it and add // fallow-ignore-next-line added-suppression, or set the rule to off for the path in overrides.
Test-only branches
The change adds a line to a non-test source file that checks the test environment. Audit reports these checks:
process.env.NODE_ENVcompared with'test'process.env.VITESTprocess.env.JEST_WORKER_ID
Such a branch lets the tests take a path that production does not take. import.meta.vitest does not report, because Vitest in-source tests use it on purpose. Test files, type-test files, and tool config files (such as vite.config.ts or .eslintrc.js) are skipped. A token inside a string literal does not report. A check that only keeps console output quiet in tests, such as if (process.env.NODE_ENV !== 'test') console.warn(...), does not report. A check that picks an env file, such as '.env.test', does not report.
| Severity | Default |
|---|---|
| Warn | Yes |
Example: a change adds if (process.env.NODE_ENV === 'test') return cached; to src/pricing.ts.
When to act: Use a seam that the tests can inject, such as a parameter or a module mock. If the branch is intended, suppress it with // fallow-ignore-next-line test-only-branch.
Diverged clone siblings
The change edits exactly one copy of a base clone group, the edit breaks the clone, and all other copies keep their text. The other copies can need the same change. The related field lists the unchanged copies.
These changes do not report:
- A cosmetic edit that keeps the clone.
- A change that removes a copy.
- A change that edits every copy.
- An edit to a test copy or a story copy (
*.stories.*,*.story.*).
The rule needs the base snapshot of --gate new-only. With --gate all, or when audit skips the base pass, the rule does not run.
This rule is off by default. Copies often differ on purpose, for example an input and an output type, a legacy copy, or one component that gets a new feature. Set the rule to warn or error to review every diverged copy.
| Severity | Default |
|---|---|
| Off | Yes |
Example: src/a/total.ts and src/b/total.ts held the same function. The change fixes a bug in the first copy only.
When to act: Look at the copies in related. Apply the same change there, or extract a shared function. If the copies must be different, suppress the finding with // fallow-ignore-next-line diverged-clone-sibling.
Added dependencies
The change adds a dependency to a package.json. A new dependency is often correct, so this rule is off by default. Set it to warn or error to review each new package.
An invented package name already fails as an unresolved import or an unlisted dependency, so this rule is not necessary to catch it.
| Severity | Default |
|---|---|
| Off | Yes |
Example: a change adds "left-pad": "^1.3.0" to dependencies.
When to act: Make sure that the package exists, is maintained, and does not repeat a dependency that the project already has. You cannot suppress this finding with a comment, because a package.json cannot hold a comment. Add an overrides entry that sets added-dependency to off for the path, or set the rule to off.
Configure diff-risk rules
Set each rule in the rules object. overrides[].rules sets a rule for some paths only.
{
"rules": {
"weakened-tests": "error",
"added-suppression": "warn",
"test-only-branch": "warn",
"diverged-clone-sibling": "warn", // opt-in, off by default
"added-dependency": "warn" // opt-in, off by default
},
"overrides": [
{ "files": ["packages/legacy/**"], "rules": { "added-suppression": "off" } }
]
}
Fallow also accepts the plural keys added-suppressions, test-only-branches, diverged-clone-siblings, and added-dependencies. The suppression token of a rule is its rule name, for example // fallow-ignore-next-line test-only-branch. added-dependency has no suppression comment. Use an overrides entry for it.
Diff-risk findings in output formats
Every output format of fallow audit shows the diff-risk findings:
-
The human output adds a Diff risk section with one line for each finding. The status line names the number of diff-risk findings.
-
--format compactprints one record for each finding:<rule>:<path>:<line>:<evidence>,severity=<severity>[,kind=<kind>]. The line is0for a finding about the whole file. Onlyweakened-testsrecords havekind. -
--format markdownadds a## Fallow: N diff-risk findingstable with the location, the rule, the severity, and the evidence of each finding. -
--format jsonand the MCPaudittool put them in thediff_risksection. See Key fields. -
--format sarifadds a run withautomationDetails.idfallow/audit/diff-risk. The rule ids arefallow/<rule>, andhelpUrilinks to this section. The unchanged clone copies arerelatedLocations. -
--format codeclimateadds issues with the check namefallow/<rule>and the categoryBug Risk. -
--format github-annotationswrites one annotation for each finding. Anerrorfinding gives::error, and awarnfinding gives::warning. -
--format github-summaryadds a Diff Risk table to the audit summary, with the rule, the location, the evidence, and the severity of each finding. The table shows 15 findings at most, and a note gives the number of other findings. -
The GitHub Action and the GitLab CI template add the diff-risk findings to the issue count of
command: audit, under both gates. -
The VS Code extension adds the diff-risk findings to the gating-candidate count of the audit status bar, and shows a diff-risk row in the tooltip.
For example, a change that skips a test and adds a @ts-ignore comment gives this output:
── Diff risk ──────────────────────────────────────
src/index.test.ts:3 weakened-tests (skip-only) [warn] it.skip
src/index.ts:2 added-suppression [warn] @ts-ignore
⚠ diff risk: 2 findings (warn) · 2 changed files (0.14s)
weakened-tests:src/index.test.ts:3:it.skip,severity=warn,kind=skip-only
added-suppression:src/index.ts:2:@ts-ignore,severity=warn
## Fallow: 2 diff-risk findings
| Location | Rule | Severity | Evidence |
|:---------|:-----|:---------|:---------|
| `src/index.test.ts:3` | `weakened-tests` / `skip-only` | warn | `it.skip` |
| `src/index.ts:2` | `added-suppression` | warn | `@ts-ignore` |
JSON output
{
"kind": "audit",
"schema_version": 13,
"version": "3.34.1",
"command": "audit",
"verdict": "fail",
"changed_files_count": 12,
"base_ref": "main",
"head_sha": "d4a2f91",
"elapsed_ms": 2140,
"summary": {
"dead_code_issues": 2,
"dead_code_has_errors": true,
"complexity_findings": 1,
"max_cyclomatic": 28,
"duplication_clone_groups": 0
},
"attribution": {
"gate": "new-only",
"dead_code_introduced": 2,
"dead_code_inherited": 0,
"complexity_introduced": 1,
"complexity_inherited": 0,
"duplication_introduced": 0,
"duplication_inherited": 0,
"styling_introduced": 0,
"styling_inherited": 0,
"duplication_demoted": 0,
"diff_risk_introduced": 1
},
"dead_code": {
"schema_version": 10,
"total_issues": 2,
"unused_exports": [
{ "path": "src/components/Button.tsx", "export_name": "OldButton", "introduced": true, "actions": [...] }
]
},
"complexity": {
"findings": [...]
},
"duplication": {
"clone_groups": [],
"clone_families": [],
"stats": { "duplication_percentage": 0.0 }
},
"diff_risk": {
"findings": [
{
"rule": "weakened-tests",
"kind": "skip-only",
"path": "src/pricing.test.ts",
"line": 14,
"message": "The change adds `it.skip`, so other tests or this test do not run",
"evidence": "it.skip",
"severity": "warn",
"introduced": true,
"actions": [
{
"type": "suppress-line",
"auto_fixable": false,
"description": "Suppress this `weakened-tests` finding with a comment above the line",
"comment": "// fallow-ignore-next-line weakened-tests"
}
]
}
]
}
}
The audit schema_version is 13. Version 13 follows the embedded health contract, where the targets[].category enum gained resolve_hidden_coupling.
Key fields
| Field | Type | Description |
|---|---|---|
verdict | "pass" | "warn" | "fail" | The audit result. Use this for CI gates. |
changed_files_count | integer | Number of files changed between base and HEAD |
base_ref | string | The git ref used for comparison |
summary.dead_code_issues | integer | Total dead code issues in changed files |
summary.dead_code_has_errors | boolean | Whether any dead code issue has error severity |
summary.dead_code_skipped / complexity_skipped / duplication_skipped | boolean | Present and true only when --only, --skip or the audit config skipped that sub-analysis. Its counts are then 0, and its dead_code, complexity or duplication section is absent. |
summary.complexity_findings | integer | Functions above the complexity thresholds |
summary.max_cyclomatic | integer | null | Highest cyclomatic complexity found (null if none) |
summary.duplication_clone_groups | integer | Clone groups involving changed files |
attribution.*_introduced | integer | Findings whose structural key did not exist at the base ref. With gate=all, these stay 0, because audit skips the base-snapshot attribution pass. |
attribution.*_inherited | integer | Findings already present at the base ref. Also 0 under gate=all. |
attribution.duplication_demoted | integer | Clone groups that the new-only gate demoted from introduced to inherited, because no instance overlaps an added line. Always present in audit-family JSON (audit, review brief, MCP audit). Demoted groups still count in duplication_inherited, so this value never exceeds it. |
dead_code.*[].introduced | boolean | Present in audit JSON sub-results when base attribution is available. true means the finding is new compared with the base ref. |
duplication.clone_groups[].demotion_reason | string | Present only on demoted groups. A kebab-case reason for the demotion, currently no-added-lines. Fallow can add more values, so treat an unknown value as "some demotion reason". fallow dupes --format json never emits this field. |
complexity.styling_findings[] | array | Present when CSS analysis is active. Holds first-class styling issues with rule severity and actions. Each item has an optional introduced attribution when the base comparison is available. |
complexity.css_analytics | object | Descriptive CSS analytics from the CSS analysis: specificity, token usage, context for duplicate declarations, and cleanup candidates. Use it as evidence for styling_findings. Do not gate on it alone. |
attribution.diff_risk_introduced | integer | The number of entries in diff_risk.findings. It counts under both gates (new-only and all), unlike the other *_introduced fields. Always present in audit-family JSON. 0 when the diff_risk section is absent. |
diff_risk.findings[].rule | string | weakened-tests, added-suppression, test-only-branch, diverged-clone-sibling, or added-dependency. See Diff-risk rules. |
diff_risk.findings[].kind | string | Only on weakened-tests: skip-only, removed-tests, assertion-drop, or coverage-threshold. |
diff_risk.findings[].path | string | Root-relative path of the changed file, with forward slashes. |
diff_risk.findings[].line | integer | 1-based head line. Absent when the finding is about the whole file, for example removed tests. |
diff_risk.findings[].message | string | One sentence that states the fact. |
diff_risk.findings[].evidence | string | The token, the count change, or the value change that supports the finding. |
diff_risk.findings[].severity | string | error or warn, from the rule config. |
diff_risk.findings[].introduced | boolean | Always true. The change introduced the finding. |
diff_risk.findings[].related | array | Optional. Related locations (path, line), such as the unchanged copies of a clone group. |
diff_risk.findings[].actions | array | Suppression actions only: suppress-line, suppress-file, or suppress-in-config. auto_fixable is always false. comment holds the suppression comment, and is absent on suppress-in-config. |
The dead_code, complexity, and duplication sub-objects hold full results in the same format as fallow dead-code, fallow health, and fallow dupes. audit omits them when no files changed. By default, the complexity sub-object can also include the css_analytics and styling_findings blocks of the health report. When you pass --runtime-coverage, it can also include runtime_coverage and additive coverage_intelligence blocks.
MCP tool
The audit MCP tool wraps fallow audit --format json --quiet --explain:
{
"tool": "audit",
"arguments": {
"base": "main",
"gate": "new-only",
"skip": ["dead-code"],
"css_deep": true
}
}
When you omit base, the tool resolves it as described under How it works. To pin it, set FALLOW_AUDIT_BASE in the server environment. Set gate to all for strict mode. Set skip (for example ["dead-code"]) or only to choose the sub-analyses, with the values and rules of --skip and --only. The tool includes styling findings by default. Set css_deep to false to skip project-wide styling reachability, or to true to force it back on when config turns it off. The tool returns the same JSON envelope as the CLI.
The audit tool description is a short summary. Read the fallow://tools/audit tool guide for the detail. The guide names the five diff-risk rules and holds the styling, coverage, complexity unit, group_by, baseline, runtime-coverage and FALLOW_DIFF_FILE detail.
See MCP integration for setup instructions.
Audit cache maintenance
Under --gate new-only, audit creates a temporary base snapshot (a git worktree checkout) to compare against the base ref.
- When the current checkout has
node_modules, audit links it into the snapshot. tsconfigextendschains into installed packages and path aliases then resolve as in the working tree. - audit unregisters the snapshot from git right after it creates it. The snapshot never appears in
git worktree list, IDE repo views, or.git/worktrees/, even if the process is force-killed. - The next audit deregisters entries that earlier fallow versions registered, and keeps their warm caches.
Each project root keeps one base-snapshot cache that the root owns. audit rebuilds it in place when the resolved base SHA changes. audit holds the reuse lock for the whole run, so cleanup can never remove files under a running audit.
audit also saves the result of each base pass as a snapshot file in the fallow cache directory, below .fallow/cache/audit-base-v<N>/ by default. The key of a file includes the base SHA, the changed files, and --brief. A later audit with the same key reads the file and does not run the base pass again.
Every audit runs an automatic age-based garbage collection (GC). The default age is 30 days. To change it, set the FALLOW_AUDIT_CACHE_MAX_AGE_DAYS env var or the audit.cacheMaxAgeDays config field. 0 turns off the age-based sweep. For the snapshot files, the age counts from the last use. After each save, audit also keeps only the newest 32 snapshot files. This cap applies also when the age is 0. Two subcommands clean the caches on demand.
audit-cache remove
To remove all caches of one project, warm or not, run fallow audit-cache remove --root <PATH>:
fallow audit-cache remove --root ./my-project --dry-run # preview what would be removed
fallow audit-cache remove --root ./my-project --yes # remove (required in non-interactive shells)
--dry-run reports what the command would remove and does not touch the filesystem. Removal in a non-interactive shell requires --yes. The command leaves lock-contended entries (an audit is in the middle of a rebuild) in place, reports them, and exits 2.
audit-cache prune
Use fallow audit-cache prune when audit caches from deleted checkouts or old branches take disk space. It cleans up now, with a visible decision per entry, and you do not wait for the silent sweep of the next audit.
prune applies the same GC policy that every fallow audit run applies silently:
- cleanup of orphaned sidecars
- age-based reclaim under the resolved threshold
- reclaim of abandoned entries whose recorded owner root no longer exists
- the age threshold and the newest-32 cap for the base-snapshot files in the fallow cache directory of
--root
It reports every cache entry that it considered, with sizes and ages. It never touches entries that other live projects own. In the fallow cache directory, it removes only files with the name pattern that audit writes.
fallow audit-cache prune --dry-run # preview every decision without touching the filesystem
fallow audit-cache prune # apply the GC policy now
fallow audit-cache prune --max-age-days 7 # tighter age threshold for this run
| Flag | Description |
|---|---|
--dry-run | Preview decisions without touching the filesystem. The command removes nothing and creates no lock or age-tracking sidecars. |
--max-age-days <N> | Age threshold in days for this run. Precedence: this flag, then FALLOW_AUDIT_CACHE_MAX_AGE_DAYS, then audit.cacheMaxAgeDays, then the 30-day default. The JSON output reports which source won. 0 turns off age-based reclaim, but the command still reclaims orphaned entries and entries whose recorded owner root is gone. |
--root is optional for prune and defaults to the current directory. The command measures sizes with a full recursive walk of each cache entry, which can take a few seconds on large caches.
Human output prints one row per considered entry, sorted by path, with the decision, reason, size, and age. An entry that stays because another live project owns it gets a hint: run audit-cache remove to clear the cache of that project. The closing line gives the total of reclaimed bytes.
prune exits 0 whenever it ran, unlike remove. This includes runs that skip entries because of lock contention (an audit is in the middle of a rebuild) and runs with per-entry failures. On a shared runner, the command reports entries of other users as kept and does not fail. Exit code 2 is only for validation errors and an unreadable scan root. Scripts gate on the complete field of the JSON envelope, not on the exit code.
With --format json, prune returns its own maintenance envelope. It is separate from the audit envelope, and sits next to the audit-cache-remove envelope:
{
"kind": "audit-cache-prune",
"schema_version": 1,
"command": "audit-cache prune",
"root": "/work/my-project",
"scan_root": "/tmp",
"dry_run": false,
"max_age_days": 30,
"max_age_source": "default",
"entries": [
{
"path": "/tmp/fallow-audit-base-cache-1a2b3c4d",
"pass": "foreign",
"disposition": "removed",
"reason": "aged-out",
"age_days": 41,
"size_bytes": 187563008,
"owner_root": "/work/deleted-checkout"
}
],
"found": 1,
"removed": 1,
"kept": 0,
"skipped": 0,
"failed": 0,
"complete": true,
"reclaimed_bytes": 187563008
}
| Field | Type | Description |
|---|---|---|
dry_run | boolean | true when the run only previewed decisions. |
max_age_days, max_age_source | integer, string | The resolved age threshold and which precedence level supplied it (flag, env, config, or default) |
entries[] | array | One item per considered cache entry: path, pass (owned, legacy, foreign, or snapshot for a base-snapshot file), disposition (removed, kept, skipped, or failed), a short kebab-case reason (for example aged-out, fresh, owner-live, or over-capacity for a snapshot file beyond the newest 32), age_days, size_bytes, and owner_root (each of the last three is null when not measurable). |
found, removed, kept, skipped, failed | integer | Aggregate counts. removed + kept + skipped + failed always equals found |
complete | boolean | true when nothing was skipped or failed. Gate on this field in scripts. The exit code stays 0 either way. |
reclaimed_bytes | integer | Bytes that this run removed from disk. On a dry run, the bytes that the run would remove. |
A normal audit run does its GC silently. To see the per-entry decisions, set RUST_LOG=fallow=debug. fallow audit and audit-cache prune then log one diagnostic line per considered entry, with its decision, reason, age, and threshold. With RUST_LOG unset, the audit output does not change.
Review brief and decision surface
fallow review shows a reviewer where to look in a change. It is an alias for fallow audit --brief. It produces a deterministic review brief from the module graph and always exits 0. The output includes the verdict for information only. fallow audit is the command that gates (exits non-zero on a fail verdict). --format is independent of --brief, so the brief renders in human or json like any other audit output.
The brief answers "where do I look?", not "will CI block this?". It runs the same analysis as the gating audit and organizes the result around four jobs:
| Job | What it does |
|---|---|
| subtract | Fallow reports dead code, complexity, and duplication for the changed files, but keeps them out of the judgment loop. The reviewer does not have to judge mechanical findings. |
| focus | Fallow ranks the changed-file units by a composite attention score and labels each one review-here or not-prioritized. It also gives a full deprioritized list. To expand that list in the human output, use --show-deprioritized. |
| structure | The decision surface: the few structural decisions in the change that have large consequences. |
| direct | The walkthrough guide for an agent (see Agent walkthrough loop). |
Decision surface
The decision surface is the ranked set of coupling-boundary, public-api-contract, and dependency decisions in the change, capped by --max-decisions. Each decision is a framed judgment question anchored to a signal_id. fallow decision-surface prints the decision surface alone. That page documents the categories, the fields of each decision, and suppression.
JSON envelope
fallow review --format json returns decisions, focus, deltas, impact_closure, partition, and graph_facts. When the project has a CODEOWNERS file, it also returns ownership. When the diff changes a value export, it also returns test_reach. The brief schema_version is 13. Consumers can use it to detect contract changes.
deltas also has the dependency keys that the decision surface reads: dependency_added (<manifest>::<name>) and dependency_major_bumped (<manifest>::<name>@<from>-><to>). The envelope names what changed even when the cap collapses the decision. Both keys are always present, as empty arrays when nothing changed.
weakening lists the guardrails that the change makes weaker. Each entry has kind, file, and evidence. The signals are advisory and never gate. The kind values are:
kind | Source | Example evidence |
|---|---|---|
test-weakened | A weakened-tests finding: a skip or focus marker, removed test cases, or fewer assertions | it.skip added, test cases: 2 -> 1, assertions: 3 -> 1 |
threshold-lowered | A weakened-tests finding of kind coverage-threshold | lines: 90 -> 70 |
suppression-added | An added-suppression finding | eslint-disable added |
security-check-removed | A security scan step that the change removes from a CI file |
The first three kinds come from the diff_risk findings of the same run, so the brief and the diff_risk section agree. They follow the rule severities and the suppressions: when you set weakened-tests or added-suppression to off, or suppress a finding with a fallow-ignore comment, the brief drops the signal. They also follow the diff-risk scope. A test that moves to another changed test file is not a removed test. A threshold signal comes only from a test-runner, coverage, or Fallow config file. A suppression signal comes only from a source or style file. Only files under the analysis root (--root) give these signals. The security-check-removed scan stays local to the brief. The JSON keys and the kind values are the same as before. The evidence text is a free string that follows the diff-risk evidence, so do not parse it.
impact_closure reports the blast radius as a magnitude, a sample, and a distribution instead of one long list:
affected_countis the exact number of files outside the diff that the change reaches, computed before any cap.affected_not_shownis a path-sorted sample of at most ten of those files. It is a prefix of the sorted set, so it clusters in the directory that sorts first. Use it to open representative files. Do not use it to list the full reach or to infer its shape.affected_by_dirgives the shape as{ dir, count }rows: heaviest directory first, the directory path as tie-breaker, at most twenty-five rows.affected_by_dir_omittedcounts the lighter directories that did not fit. The count in every row is exact.coordination_gapis separate and has no cap. It is not a subset of the sample. It skips, on purpose, the story and test consumers that the affected set counts.search_gaplists the consumers that a text search for a changed export name does not find (missed) or finds only under another name (renamed). Only exports whose declaration the diff changes count, and only consumers outside the diff. For a.vue,.svelte,.astro, or.mdxfile, fallow reads the declaration ranges from the script blocks (SFC<script>blocks, Astro frontmatter, and MDX statements). So a change inside a declaration body in such a file counts.exports[]has one entry per export (file,export_name,line,total) with at most 10consumers[](consumer_file,line,search_visibility,reason,search_as),missedfirst.totalis the sum of the per-export totals before the cap. A pass-through consumer (a barrel, or a file that imports the export and only exports it again) is never a gap. When no such consumer exists, the key is absent. See Search visibility in traces for the field values.co_change_gaplists the changed files that have a hidden change-coupling partner outside the diff. The two files changed together in most commits of the history window, but no import or re-export chain connects them, so the graph facts cannot point at the partner. Each entry haschanged_file,partner_file,shared_revisions(the commits that changed both files),degree(the coupling degree, from 0 to 1), andnote. The fact is advisory. It is history evidence, not a proof that the partner must change, and it never gates. The list has at most ten entries, highestdegreefirst, then the mostshared_revisions.co_change_gap_countgives the full number of gaps before the cap. The gaps applyhealth.ignoreand the workspace filter (--workspace,--changed-workspaces), the same as thechange_couplinghealth section. Fallow omits both fields when there is no gap.
partition has independent_slices only when there are two or more slices. A slice is a connected component of the dependency graph between units, given as a sorted list of module directories. Two or more slices mean that the change splits along a seam that the graph proves, into pieces that you can review and merge separately. A single slice would only repeat order, so fallow leaves the field out. The field helps you orient. It never asks you to split the change.
ownership shows how many CODEOWNERS owner groups the change reaches:
group_countcounts the owner groups of the changed files and of the impact closure.(unowned)counts as one group.transitive_only_countcounts the groups that own no changed file and that the change reaches only through the impact closure.unowned_direct_countcounts the changed files that no CODEOWNERS rule owns.groupsis a capped list of{ owner, direct_count, affected_count }rows, sorted bydirect_count(highest first), thenaffected_count(highest first), thenowner.groups_omittedcounts the groups that did not fit.slicesis present only whenpartition.independent_slicesis present, with the same order. Each entry is{ module_dirs, owners, separable }.separableis true when the slice has one owner.
Each file maps to its primary owner: the last matching rule, and the first owner of that rule. The section reads only the CODEOWNERS file (the configured codeowners path, else the standard locations), so it does not need git history. routing reads the same configured path. Without a readable CODEOWNERS file, the ownership key is absent. A configured path that fallow cannot read prints a warning. The human output lists the owner groups, and names each slice that has one owner. Fallow does not split the change: the reviewer decides.
test_reach states how tests reach each value export that the diff changes. An export counts when its declaring statement overlaps an added line. Type-only exports, re-exports and exports of test files do not count. Fallow reads the reach from the module graph, so it needs no coverage run. Each export gets one reach class:
reach | Meaning |
|---|---|
direct | A test file references the export. |
transitive | Tests reach the export only through other modules. |
mocked-only | Only tests that replace a module on the import path with a closed vi.mock or jest.mock factory reference the export. The real code does not run in those tests. |
none | Tests load the declaring file, but no test-reachable module references the export. |
none-file | No test loads the declaring file. |
framework | A framework reads the export as configuration with a literal value, for example the Next.js dynamic or revalidate option. It needs no test. |
The object has these keys:
statusismeasured, orno-testswhen the project has no test files. Withno-tests, every export counts asnone-fileandexportsis empty.maskingisactivewhen a test uses a closed mock factory,no-mockswhen no test does, andcappedwhen the tests use more than 1024 distinct mock sets. Withcapped, mock detection is off and a mocked module counts as reached.totalis the number of changed value exports, andcountshas one key perreachvalue.exportslists at most 25 exports that need attention:mocked-only,none,none-fileandtransitive, in that order. Inside a class, changed declarations (modified) come before new ones (new), then the order is file, line and name. Each row hasfile,export_name,line,reachandchange.changeisnewwhen the file did not export the name at the base of the diff: a new export, a renamed export, or an export of a new file. An edited, rewritten or moved declaration ismodified. Amocked-onlyrow addstests(the mocking test files, at most three),tests_total,test_changed(one of those tests changed in the diff) andmocked_targets(the mocked modules, which can be a barrel in front of the declaring file).exports_omittedcounts the rows past the cap.e2e_runneristruewhen the project uses Playwright or Cypress. End-to-end tests drive the running app and do not import source files, so thereachclasses do not count them. The key is absent when no such runner is present.
Treat reach as an open set: a later version can add a class. Read an unknown value as "needs attention". The section is advisory and never changes the verdict or the exit code. The human brief prints the counts and the first three rows.
Some limits apply. A change inside a non-exported helper is not attributed to the exports that call it. An automock without a factory (vi.mock('./x')) counts as reach. Code that only a setup file imports, and pages that only end-to-end tests visit, get none-file. A test that imports a file only with import type does not load the file, so that file also gets none-file.
Agent walkthrough loop
The walkthrough guide turns the brief into a strict contract with a coding agent. The graph checks the agent, not a second model. Fallow validates every judgment that the agent returns against the live graph. Fallow fences the free-text framing of the agent as non-deterministic, and that framing never gates.
1. Fetch the guide.
fallow review --base main --walkthrough-guide --format json
The response has digest (brief + decision surface), direction, graph_snapshot_hash, agent_schema, injection_note, emitted_signal_ids, and change_anchors. Fallow builds the digest from the graph only and never adds PR prose, so the digest resists injection. The snapshot hash is pinned into the digest.
Each direction.units[] entry has test_adjacency:
none: no test file imports the changed unit directly.untouched: a test imports the unit, and none of those tests is in the diff.changed: at least one importing test changed together with the unit.
The field is absent when the graph was not retained or the unit is itself a test file. It is a graph fact about direct importers, not a coverage claim. Mocks, fixtures and snapshots help tests but are not tests, so an importer under __mocks__/, fixtures/, __fixtures__/ or __snapshots__/, or a *.fixture.* file, does not count. An importer inside a test directory, such as test/fixtures/, still counts.
agent_schema publishes two lists:
action_vocabulary(block,address,consider,fyi): the closed set of values for theactionof a judgment.- a recommended
concern_vocabularywith the thirteen trade-off lenses:abstraction,coupling,data-model,error-handling,control-flow,performance,dependencies,api-ergonomics,compatibility,state-ownership,extensibility,testability,trust-boundary.
concern stays free text in the JSON. A value from the list lets a review tool group judgments by lens.
change_anchors has one stable chg: id per changed region (hunk), derived from the same diff source that the run used. The id is content-addressed (file path + normalized added text, without line numbers). It survives an edit above the hunk and a whitespace-only change, and it survives a rename through previous_change_anchor. With a change anchor, an agent can anchor a judgment about a changed REGION that has no graph finding (a trade-off with no signal_id). Fallow can then validate that broader trade-off, so the agent cannot invent it.
2. The agent returns judgment JSON. Each judgment cites a signal_id OR a change_anchor that fallow emitted. The agent echoes the hash.
{
"graph_snapshot_hash": "<echoed>",
"judgments": [
{
"signal_id": "<one fallow emitted>",
"change_anchor": "<or one fallow emitted chg: id, for a changed region with no finding>",
"framing": "<reasoning>",
"concern": "<optional>",
"action": "<optional: block | address | consider | fyi>"
}
]
}
action tells the author of the change what to do with the judgment. block and address are required actions, consider is optional, and fyi needs nothing. It is the instruction of the reviewer. Fallow fences it together with the framing, and it never gates.
3. Post-validate the judgment against the live graph.
fallow review --base main --walkthrough-file judgment.json --format json
Fallow sorts each judgment into one of these outcomes:
| Outcome | Condition | Shape |
|---|---|---|
accepted (signal) | The signal_id is anchored and the snapshot still matches. | Framing fenced deterministic: false; anchor_kind: "signal" (the strong anchor, a graph finding). |
accepted (change) | No signal_id, but the change_anchor is one fallow emitted and the snapshot still matches. | Framing fenced deterministic: false; anchor_kind: "change" (the weaker anchor, a changed region only). |
rejected (unanchored) | The signal_id is not one fallow emitted (a hallucinated id). | reason: "unanchored-signal-id". |
rejected (unknown change anchor) | The change_anchor is not one fallow emitted (a hallucinated region). | reason: "unknown-change-anchor". |
rejected (invalid action) | The anchor resolved, but the action is outside the closed vocabulary. When a judgment has a hallucinated anchor and a bad label, fallow rejects it for the anchor, never as invalid-action. | reason: "invalid-action", invalid_value: "<the offending label>" so the agent can fix it in one round trip. |
rejected (stale) | The echoed graph-snapshot hash no longer matches (the tree moved). | reason: "stale-snapshot", stale: true. Re-fetch the guide. |
An accepted judgment echoes action next to agent_framing when the agent set one.