Fallow reports one analysis through several commands and several surfaces. The rules below say which results must agree. A result that breaks a rule is drift.
The drift harness in crates/cli/tests/drift/ generates small projects and
checks the rules that have the status "checked by the harness". A rule with the
status "pending" breaks on main today. The change that fixes it also adds it
to the harness, as the test that failed before the fix.
-
Surface: one way to get a result. The harness drives three surfaces:
- the CLI binary with
--format json. I7 also reads the exit code of the human format, - the
fallow-mcpserver over stdio JSON-RPC, on the typed path (in-processfallow_api) and on the CLI-fallback path (afallowsubprocess), fallow_apiin-process. It stands in for the Node bindings, which call the same functions. The Node testcrates/napi/test.mjschecks the marshaling of the Node bindings: it compares the finding keys ofdetectDeadCode,detectDuplicationandcomputeHealthwith the CLIdead-code,dupesandhealthoutput, without a scope and with one workspace.
The LSP server is not in the contract yet. It needs a scripted editor session.
- the CLI binary with
-
Finding key: the identity of one finding. It is the tuple (issue kind, root-relative path, symbol or package name, line). The harness compares key sets, not presentation fields such as
actions, columns, or prose. -
Volatile field: a field that changes between two runs of the same analysis.
canonical_reportincrates/cli/tests/common/mod.rsremoves them before a comparison:elapsed_msandhead_sha, at any depth (VOLATILE_REPORT_FIELDS),_meta.telemetry.analysis_run_id.
The harness builds keys with one normalizer per envelope shape
(crates/cli/tests/drift/keys.rs):
| Envelope | Issue kind | Path | Symbol | Line |
|---|---|---|---|---|
| Dead code | The array name, for example unused_exports |
path, or the files joined with ->. A package cycle joins the sorted path values of its edges |
The first field that is present, in this order: export_name, package_name, member_name, name, specifier, entry_name, catalog_name. When the finding has a parent_name, the key is parent_name.symbol. A package cycle joins its packages with -> |
line, or 0 |
| Dupes | code-duplication, one key for each clone group |
The instance files joined with -> |
Each instance as file:start-end |
The first start line |
| Health | complexity, one key for each entry in findings |
path |
Function name |
line |
| Combined | The three sections above | |||
| Audit | The three sections above, split into introduced and inherited |
An MCP result goes through the normalizer of the envelope in its text content.
The keys are sets, so the harness does not compare the order of an array. The
generator also writes one duplicated function, so a project has one clone
family at most and no family order to compare. Unit tests guard the order of
the duplication arrays instead. DuplicationReport::sort sets the order, and
group_into_families builds the families in the same order. So a filter that
rebuilds the families keeps the order, and every scope filter and the baseline
filter also sort the report through duplicates::refresh_scoped_report. The
tests are in crates/engine/src/changed_files.rs,
crates/engine/src/baseline/mod.rs and
crates/engine/src/duplication_detector/families.rs.
| ID | Rule | Status |
|---|---|---|
| I1 | check output equals dead-code output |
Checked by the harness |
| I2 | Finding sets are equal on every surface | Checked by the harness |
| I3 | Each section of bare fallow equals its standalone command |
Checked by the harness |
| I4 | Audit attribution covers the head findings in changed files | Checked by the harness |
| I5 | Audit gives the same result on every surface | Checked by the harness |
| I6 | A suppression or a baseline entry never adds a finding | Checked by the harness |
| I7 | Every machine envelope carries the verdict of the human run | Checked by the harness |
| I8 | Scope flags narrow the same way on every command and surface | Checked by the harness |
| I9 | The --performance work counters do not depend on the thread count or the command alias |
Checked by the harness |
| I10 | Every dead-code finding has a finding_id that is unique in the run and equal on every surface |
Checked by the harness |
| I11 | A finding-id query gives the same findings and the same answer on every surface | Checked by the harness |
| I12 | Every security finding has a finding_id that is unique in the run and equal on every surface |
Checked by the harness |
| I13 | A --group selector and a --trend-from baseline give the same groups and group trend status on every surface |
Checked by the harness |
| I14 | architecture reports the findings of dead-code with the five structure flags, under its own kind |
Checked by the harness |
- Statement:
fallow checkandfallow dead-codewith the same flags give byte-identical JSON and the same exit code. - Surfaces: CLI.
- Comparison: the full JSON report, after the volatile fields are removed.
- Designed exceptions: the volatile fields.
- Status: checked by the harness.
- Statement: for the same project and flags, the dead-code, dupes and
health finding sets are equal on the CLI, on MCP (typed path and
CLI-fallback path) and on
fallow_apiin-process. - Surfaces: CLI
dead-code,dupesandhealth; MCPanalyze,find_dupesandcheck_health;fallow_api::run_dead_code,run_duplicationandrun_health. A separate case compares CLIarchitecture, MCPcheck_architecture(typed path, and the CLI path through abaselinewith no entries) andfallow_api::run_architecture, and also compares their finding ids. - Comparison: finding keys.
- Designed exceptions: health compares only
findings. The other health sections (file scores, hotspots, targets) are not finding sets. - Status: checked by the harness.
- Statement: each section of bare
fallowequals the standalone command with the same flags and baselines. - Surfaces: CLI bare
fallowagainstdead-code,dupesandhealth. Barefallowloads the baselines with--baseline,--dupes-baselineand--health-baseline, the namesaudituses. - Comparison: finding keys for each section. The harness compares each project twice: without baselines, and with a partial baseline of each analysis (the same partial files as I6).
- Positive control: on the fixed project, a full baseline of each
analysis empties the matching section of bare
fallow, and each section still equals its standalone command. - Designed exceptions: none.
- Status: checked by the harness.
- Statement: the introduced findings plus the inherited findings of
auditequal the head findings in the changed files. A finding is introduced when its key is absent at the base commit, after the base keys follow renames. Dependency findings are in scope only when the manifest changed. - Surfaces: CLI
audit. - Comparison: finding keys, split by attribution. The harness builds the
expected split without the audit code:
- The head findings are the keys of
dead-code,dupesandhealthon the head commit. A key is in scope when one of its paths is a changed file: a file whose content differs from the base commit, or a new path. A dependency finding has its manifest as its path. - The base findings are the keys of the same commands on a copy of the base commit. Their paths follow the renames of the head commit.
- A rename counts only when git detects it (
git diff --find-renames, default similarity threshold), so an edit that drops the similarity below the threshold makes a delete plus an add. - A head key is introduced when no base key has the same identity: the kind, the paths and the symbol, without line numbers. A clone group has the line counts of its instances in its identity in place of its symbol, because its symbol holds line ranges. The audit key of a clone group holds its size, so a clone that grows with added lines is a new clone group.
- A clone group with a new identity is inherited when none of its
instances holds an added line of the diff against the base commit. This
models the clone-group demotion of
new-only(#2164): the change did not write the duplicated text.
- The head findings are the keys of
- Positive control: on a fixed project whose head commit renames a file with an unused export and adds a dependency to the manifest, the expected split holds the moved export and the old dependency as inherited and the new dependency as introduced, and the audit matches it. Git detects the rename of the control. The same project without the manifest change has no dependency finding in the audit.
- Designed exceptions: none.
- Status: checked by the harness.
- Statement: CLI
audit, MCPauditandfallow_api::run_auditgive the same introduced set, the same inherited set and the same verdict. - Surfaces: CLI, MCP (typed path),
fallow_apiin-process. The MCP server of this check hasFALLOW_BINset to a file that does not exist, so a CLI fallback cannot answer the call. - Comparison: finding keys, split by attribution, and the verdict.
- Positive control: the fixed project of the I4 control, on all three surfaces, with and without the manifest change. Without the manifest change, no surface reports a dependency finding.
- Designed exceptions: none. All three surfaces run one implementation,
fallow_api::audit_run. Each surface only supplies the runners of the three analyses and the base checkout. - Status: checked by the harness.
-
Statement: adding a suppression comment or a baseline entry never adds a finding, on any command.
-
Surfaces: CLI
dead-code,dupes,healthand barefallowfor suppression comments; CLIdead-code,dupesandhealthfor baselines. For a dead-code baseline,fallow_api::run_dead_code_with_baselinemust also give the same keys as the CLI with the partial baseline. Both read the file withfallow_engine::baseline::apply_dead_code_baseline. -
Comparison: finding keys. The harness renders each project twice. One copy has the suppression comments, the other has plain comments on the same lines, so no finding moves to another line. For baselines, the harness saves a baseline, keeps a part of its entries, and checks that the full baseline reports a subset of the partial baseline, which reports a subset of no baseline.
-
Positive control: on a fixed project, each finding with a suppression comment disappears from
dead-code,dupes,healthand barefallow, and a full baseline removes every finding of each analysis. Without this control, a run that ignores comments and baselines passes every subset check. -
Finding ids: a dead-code finding that stays in the report keeps its
finding_id. The harness compares the ids of the findings with the same key in these pairs of runs:- with suppression comments and with plain comments,
- with the partial baseline and without a baseline,
- with the full baseline and with the partial baseline.
The CLI and
fallow_apiwith the partial baseline give the same findings with the same ids. -
Designed exceptions:
stale_suppressionsfindings. They report the suppression comment itself when it matches nothing. -
Status: checked by the harness.
- Statement: every machine envelope carries a verdict in
gate_outcomes, and that verdict equals the verdict of the human run. The exit code follows the documented rule for each command. - Surfaces: CLI
dead-code,architecture,dupes,health,security,auditand barefallow, in JSON and in the human format.dead-code,architecture,dupes,healthand barefallowalso in grouped JSON (--group-by directory). MCPanalyze,find_dupesandcheck_healthon the CLI-fallback path. Each case also arms gates beyond the default rules:security --gate new --changed-sinceagainst the base commit,dead-codeand barefallowwith--fail-on-regressionagainst a regression baseline. The baseline holds the counts of the head commit, or zero counts, so the gate passes in some cases and fails in others.- bare
fallow --fail-on-issues, which enforces every failing entry.
- Comparison: the stated verdict of
gate_outcomesand the exit codes. A run fails when an entry hasstatus: "fail". The machine run fails when such an entry is alsoenforced. A failed gate exits 1, exceptsecurity --gate, which exits 8.- Standalone commands: the JSON exit code, the grouped JSON exit code and the human exit code all equal the code of the enforced entries that fail.
- Bare
fallow: the JSON runs exit with the code of the enforced entries that fail, and the human run exits 1 exactly when an entry hasstatus: "fail". - Bare
fallow --fail-on-issues: every failing entry is enforced. The JSON exit code, the grouped JSON exit code and the human exit code all equal the code of the enforced entries that fail. - MCP: the CLI-fallback result states the same verdict as the CLI run.
- Positive control:
- On the fixed project, bare
fallow --format jsonstates a failing verdict and exits 0, and the human run exits 1. With--fail-on-issues, the same JSON run states an enforced failure and exits 1. - Each armed gate fails on a fixed project, and the exit code follows:
--fail-on-regressionagainst zero counts and--fail-on-stale-baselineagainst a baseline with stale entries exit 1 ondead-codeand on barefallow --format json.security --gate newon a head commit that adds a sink exits 8. - A fixed project with a
.fallowrc.jsonruns every I7 command. Anoverridesentry forpackage.jsonsets the severity of an unused dependency override, anddead-code --changed-sinceand both audit gates reach the verdict that the override sets. The same project holds a duplicate export that onlyignoreFindingsfiles hold after--changed-since, and every surface hides it (I8). The generator writes no config file, so only this project reaches per-file severity.
- On the fixed project, bare
- Designed exceptions:
- Bare
fallowin a machine format exits 0 when it has findings, unless--fail-on-issuesor--ciis set. Without these flags, its entries reportenforced: false, exceptregression,stale-baseline,baseline-growth,type-aware-requireandparse-error. With one of these flags,error-severity-findings,health-findings,duplication-thresholdandduplication-findingsare also enforced, and every output format exits 1 when one of them fails. Without the flags, the difference between the machine formats and the human,compactandmarkdownruns is a compatibility rule. The bare run printed its machine formats without an exit rule from its first release. #2642 and #2810 kept that rule and moved the verdict intogate_outcomes, where the GitHub Action and the GitLab template read it and fail the job themselves. dupeshas no default exit rule. Its envelope carriesgate_outcomesonly when a gate armed, and an absent object means that the run passed.--fail-on-issuesand--ciarmduplication-findings.fallow_apiand the MCP typed path run no CLI gate and publish nogate_outcomes, so the harness compares verdicts only across the CLI and the MCP tools that return the CLI envelope.- A stale-baseline verdict that
--fail-on-stale-baselinedid not arm hasstatus: "fail"and does not fail the human run. The generated cases run I7 without baselines. The positive control arms the gate. - The harness cannot make the type-aware pass incomplete, so it does not
arm
type-aware-require. Unit tests incrates/cli/src/gates.rspin that entry against the exit code. - SARIF and CodeClimate have no place for a verdict.
- Bare
- Status: checked by the harness.
- Statement:
--changed-since,--workspaceand--productionnarrow the same way on every command and every surface. - Surfaces: the surfaces of I2. MCP
check_changedtakes the place ofanalyzewhen the run has--changed-since. - Comparison: for each scope flag and each analysis:
- the finding keys are equal on every surface,
- the scoped run holds no key that the run without the flag lacks
(
--changed-sinceand--workspaceonly), - every key touches the scope: a changed file, or a path in the selected
workspace package (
--changed-sinceand--workspaceonly). - for dead code, every surface gives the same findings with the same
finding_id(I10), and a finding of the scoped run has the id of the same finding in the run without the flag (--changed-sinceand--workspaceonly).
- Designed exceptions:
--changed-sincekeeps dependency-level findings (for exampleunused_dependencies) whatever changed. Whether a dependency is unused is a fact about the whole graph, not about one file.- A clone group is in scope when one of its instances is in scope.
- MCP
find_dupeshas noproductionparameter, so the harness does not compare MCP for dupes with--production. --productioncan add findings, so it has only the equality check.
- Positive control: on a fixed workspace project, one clone group has an
instance in
pkg-aand an instance inpkg-b. With--workspace pkg-a, every surface keeps the whole group. Without this control, a generator that never puts a clone in two packages passes I8 without a real check. - Status: checked by the harness. The scope filters have one
implementation each in
fallow-engine:dead_code::apply_scope,duplicates::apply_scopeand the diff filters indiff_scope. The CLI andfallow_apicall them.
- Statement: the
countersobject of--performanceis the same fordead-codewith one thread,dead-codewith four threads andcheckwith four threads on the same project. - Surfaces: CLI
dead-codeandcheck, with--performance --no-cache. - Comparison: the full
countersobject, with exact equality. The dead-code findings of the three runs also have the samefinding_idvalues. - Designed exceptions: none. The millisecond fields are not compared, because they change from run to run.
- Status: checked by the harness. The exact values for three pinned
fixtures are in
performance_counters_are_exact_on_pinned_fixturesincrates/cli/tests/integration/check_tests.rs.
- Statement: every dead-code finding carries a
finding_idthat starts withdc1:. No two findings of one run share an id. For the same project and flags, each finding has the same id on the CLI, on MCP (typed path and CLI-fallback path) and onfallow_apiin-process. - Surfaces: CLI
dead-code, MCPanalyze,fallow_api::run_dead_code. The Node testcrates/napi/test.mjscompares the ids ofdetectDeadCodewith the CLI on its parity fixture. - Comparison: the list of (finding key,
finding_id) pairs of each surface, with exact equality. A list and not a set, so a duplicate id stays visible. - Positive control: the fixed project of I6 has more than one dead-code
finding, and the CLI and
fallow_apigive each one an id. Without this control, a generator that makes no dead-code finding passes I10 without a real check. The generator imports only inside one package, so it never makes a package cycle. A second fixed project has two workspace packages that import each other. Every surface reports itspackage_cyclesfinding with the samedc1:package-cycle:id, without a scope and with--workspace, and the scoped run keeps the id of the unscoped run. - Designed exceptions: a finding that is absent from a scoped run, or from a run with other config, is not resolved. Its state is unknown in that run. I6, I8 and I9 compare ids only for the findings that both runs report. Each run of such a pair must first pass the I10 run check, so two runs without ids do not agree.
- Status: checked by the harness.
run_engine_owned_dead_code_pipelineincrates/engine/src/session.rsstamps the ids once, before the filters. Every surface reaches dead-code results through that pipeline.
- Statement: a dead-code run with a finding-id filter reports exactly the
requested findings that the full run reports, and every surface gives the
same
finding_id_queryobject. - Surfaces: CLI
dead-code --finding-id, MCPanalyzewithfinding_ids(typed path and CLI-fallback path), andfallow_api::run_dead_codewithDeadCodeOptions::finding_ids. - Comparison: the query asks for every other id of the full CLI run plus
one well-formed id that no project reports. The sorted ids in each envelope
must equal the requested ids that the full run reports. The
finding_id_queryobjects must be equal,analysis_fingerprintincluded. - Designed exceptions: none.
- Status: checked by the harness.
- Statement: every security finding carries a
finding_idof 16 lowercase hex digits. No two findings of one run share an id. For the same project and config, each security finding has the same id on the CLI, on MCP and onfallow_apiin-process. - Surfaces: CLI
security, MCPsecurity_candidates(this tool always runs the CLI, so it has no typed path), andfallow_api::run_dead_codewith a config that turns the security rules on. The programmatic envelope does not serialize security findings, so the harness reads the typed results and makes the paths root-relative. - Comparison: the list of (rule, path, line, column,
finding_id) entries of each surface, with exact equality. - Positive control: the generator writes no security sink. A fixed project
has a config that turns both security rules on, and four sinks in two files.
Two of the sinks are on one line.
i12_control_security_ids_agree_on_cli_and_apiruns without the MCP binary. - Designed exceptions: none. The id contains the line and column, so it changes when code above the finding moves. This is the security id scheme, not drift.
- Status: checked by the harness.
run_engine_owned_dead_code_pipelineincrates/engine/src/session.rsstamps the ids once, directly after detection, withfallow_security::stamp_security_finding_ids. The LSP reads the same results and setsDiagnostic.data.findingId. A unit test incrates/lsp/src/tests.rspins that value to the CLI id.
- Statement: for the same project,
health --group-by directorywith a--groupselector and a--trend-fromsnapshot keeps the same groups, echoes the samegroup_filter, records the samegroup-filterrequestscope_size, and gives each group the sametrend_status,severity_critical_countandhotspot_counton the CLI and on MCP. - Surfaces: CLI
health, and MCPcheck_healthwithgroup_by,groupandtrend_from. Both parameters force the CLI path, so MCP has no typed path here.fallow_apihas no group selector. - Comparison: the projected group fields of each envelope, with exact
equality. The selected group must have
trend_status: compared. - Designed exceptions: a generated project without any group skips the check.
- Status: checked by the harness.
- Statement:
fallow architectureandfallow dead-code --circular-deps --re-export-cycles --package-cycles --boundary-violations --policy-violationsgive the same exit code, finding keys, finding ids,gate_outcomesand report body. The rootkindisarchitectureon one side anddead-codeon the other, and the same holds with--group-by directory(architecture-groupedanddead-code-grouped). - Surfaces: CLI, flat and grouped JSON.
- Comparison: the full JSON report, after the volatile fields and the
root members that name the command (
kind,schema_version,_meta,next_steps) are removed. The rootkindof each side is asserted. - Designed exceptions: the volatile fields and the members that name the command.
- Status: checked by the harness.
The generator (crates/cli/tests/drift/model.rs) is a proptest strategy for a
small project:
- TypeScript files with used and unused exports, value exports and type exports, and imports between files,
package.jsondependencies, used and unused, independenciesanddevDependencies,- inline suppression comments (
// fallow-ignore-next-line <kind>and// fallow-ignore-file unused-file), - a duplicated function and a function above the complexity thresholds,
- an optional npm workspaces layout with two packages,
- a base commit and a head commit. The diff adds, edits, renames (
git mv) and deletes files, and can add an unused dependency to a manifest.
The generator writes no config file. The fixed projects of the positive
controls in crates/cli/tests/drift/main.rs cover config: overrides,
ignoreFindings and baselines.
The runners are in crates/cli/tests/drift/surfaces.rs. The MCP runner starts
the fallow-mcp binary next to the fallow binary, with FALLOW_BIN set to
the CLI under test. To force the CLI-fallback path, it passes save_baseline.
Each analysis tool sends a baseline parameter to the CLI, and saving a baseline
does not change the findings of the run. The runner then checks that the
baseline file exists, which proves that the fallback path ran.
The runners remove FALLOW_* variables from each child process, so a developer
shell cannot change one surface and not the others.
| Setting | Default | Meaning |
|---|---|---|
FALLOW_DRIFT_CASES |
A small fixed count | Cases for each invariant |
FALLOW_DRIFT_SEED |
A fixed seed | A number, or random |
- The
driftjob in.github/workflows/ci.ymlruns the harness with the fixed seed on each pull request that changescrates/**. It blocks the merge. - The
drift-fulljob in.github/workflows/release-validation.ymlruns a large case count with a random seed. Release validation gates publication and also runs every week.
The harness needs the fallow-mcp binary. When the binary is missing, the
harness fails with the build command. It never skips the MCP surface.
cargo test -p fallow-cli does not rebuild fallow-mcp. When a source file
of fallow-mcp is newer than the binary, the harness fails with the same
build command.
cargo build -p fallow-mcp
cargo test -p fallow-cli --test drift -- --include-ignoredThe invariant tests are marked #[ignore], because a workspace-wide
cargo test does not build fallow-mcp. The drift CI job and the
release validation job run them with --include-ignored.
A failure message gives the seed, the case count, the shrunk project with its files, and the difference between the two key sets.
-
Build the binaries:
cargo build -p fallow-mcp. A local run needs this step again after each change outsidecrates/cli. -
Run the failing test with the same seed and case count:
FALLOW_DRIFT_SEED=<seed> FALLOW_DRIFT_CASES=<cases> \ cargo test -p fallow-cli --test drift <test name> -- --include-ignored
-
Proptest writes the failing case to
crates/cli/tests/drift/drift.proptest-regressions. Commit that line with the fix. The harness replays each saved case before it generates new cases.
An analysis subcommand reaches many surfaces. The table in
crates/types/src/command_surfaces.rs is the list of them, and tests read it
on each surface, so a new command that misses one fails a test:
- Add a
COMMAND_ENVELOPESrow with the JSONkind, the grouped kind, thereport --fromstatus, the MCP tool (or the reason there is none), whether the drift harness compares its verdict, and whether the CI integrations run it. A visible subcommand that writes no analysis report goes inCOMMANDS_WITHOUT_ANALYSIS_ENVELOPEwith its reason.every_visible_subcommand_is_classified_for_its_machine_contractincrates/cli/src/lib.rsfails until the command is in one list. - Add the
FallowOutputvariant and theFALLOW_OUTPUT_VARIANTSrow, then runnpm run generate:contracts.every_command_envelope_kind_is_a_schema_kindandfallow_output_tags_match_the_kind_tableinschema_emit.rscheck both. - For a row that renders, route the kind in
cli_report.rsand in every saved renderer, and add a parity case toPARITY_CASESinreport_parity_tests.rs.report_from_follows_the_command_envelope_tableandevery_report_from_command_has_a_parity_casecheck both. The parity test thatPARITY_CASESnames must runfallow <command>. - Add the MCP tool and its
MCP_TOOLSrow.every_row_has_an_mcp_tool_or_a_reasonchecks that the tool namesfallow <command>as its CLI analogue. - Add a live
run_and_validatecase for each kind tocli_json_documents_conform_to_output_schemainschema_conformance.rs. The test records each validated kind and fails when a row kind has no case.CONFORMANCE_EXEMPT_KINDSnames the kinds the suite cannot produce. - For a row with a verdict, add a
VERDICT_COMMANDSentry incrates/cli/tests/drift/main.rs.every_verdict_command_row_has_an_i7_casechecks it. - For a row with
CiIntegration::Routed, add the command to the valid-command list inaction/scripts/analyze.sh,ci/gitlab-ci.ymlandcrates/cli/templates/ci/gitlab-ci.yml, and route it inaction/scripts/summary.sh,annotate.shand the GitLab scripts.ci_command_lists_follow_the_command_tablechecks the valid-command lists and the summary routing. A command that CI does not run getsCiIntegration::Omittedwith a reason.
The table does not cover the human output, the docs, the annotation and comment routing of the CI scripts, or the companion repositories. Check those by hand.
- Write the predicate in
crates/cli/tests/drift/invariants.rs. It returnsOk(())or a readable difference. - When the invariant reads a new envelope shape, add a normalizer to
crates/cli/tests/drift/keys.rs. - Add a test in
crates/cli/tests/drift/main.rsthat callsrun_invariant. - Add a section to this document with the status "checked by the harness".
- When the invariant fails on
main, fix the drift in the same change. Do not add the invariant with an expected failure.