Use this reference for command parsing, orchestration, mutation, and rendering.
Use live fallow --help and generated contracts for the complete public
inventory.
crates/cli/src/main.rsis a thin binary delegator.crates/cli/src/lib.rsowns the run entry point and the multicall surface.crates/cli/src/cli_args.rsowns the Clap definitions.crates/cli/src/dispatch.rsowns subcommand dispatch.crates/cli/src/check/,audit.rs,dupes.rs,health/,security.rs, andcoverage/translate CLI options into engine or API calls.crates/cli/src/report/owns terminal rendering and CLI format dispatch.crates/cli/src/fix/owns mutation planning and application.crates/api/owns reusable typed execution and output assembly.crates/engine/owns analysis, duplication, health, discovery, and command-neutral project state.crates/output/owns serialized report types and stable envelopes.
Analysis logic belongs below the CLI. A CLI module may validate arguments, resolve paths, select an execution mode, render a result, and map the result to an exit code.
A decision that more than one command or surface makes has one implementation.
The scope filters are fallow_engine::dead_code::apply_scope,
fallow_engine::duplicates::apply_scope and fallow_engine::diff_scope. The
change scope of a run is fallow_engine::change_scope::ChangeScope. Every
surface describes its inputs with a ChangeScopeRequest (the owner, whether a
global ref was requested, the changed files, and the run-wide
PackageBaselineCache) and calls ChangeScope::resolve, which owns the
precedence rule and the failure policy. The resolved value owns the result
filter, the package_baselines provenance rows, the package-baselines
request outcome, and the scope_reason that the check baseline
comparison, --fail-on-stale-baseline, and finding-id queries read. A surface
does not assemble these from separate decisions.
A changed-file set wins. A requested global ref gives the full scope when it
does not resolve, and it still suppresses workspaces.changedSince. A run whose
owner is ChangeScopeOwner::Caller never reads the package map: audit owns
the scope of its head and base runs, on the CLI and in the programmatic API, in
every production-mode split. The base snapshot may not be a Git repository,
and a package map that hid a base finding would report the head finding as
introduced.
A key of the package map is a workspace root exactly as discovery reports it,
which is what fallow list --workspaces prints. A symlinked workspace is
mapped under its link path. The nearest workspace owns a file; unlisted
workspaces and root files remain in full scope. Dependency-level findings
retain their existing global behavior, while manifest-owned findings follow
their owner path. The map scopes check, dead-code, dupes, and those
sections of a combined run. It does not scope health or security, alone or
in a combined run. A --diff-file or --workspace scope applies on top of the
map, so a finding must be in both.
The failure policy follows --changed-since. The config loader rejects a
malformed key (absolute, .., ., empty segment, trailing slash, backslash)
for every command, and a malformed ref fails the run with exit 2. A map that
cannot apply as written stands down as a whole: a key that names no workspace
of this project (for example in a run from a package subdirectory, which loads
the parent config), or a well-formed ref that Git cannot resolve (for example
in a shallow CI clone). The run then reports in full scope, writes a warning,
and publishes request_outcomes["package-baselines"] as not-applied with
the reason unknown-workspace, git-failed, git-missing, or
not-a-repository. A run that applies the map publishes the entry as
applied. The report is wider than asked, never narrower.
The analyses of one run share one resolution: the CLI keeps it in
requests::package_baseline_cache, and the programmatic API keeps it on the
call context. check, the API runtimes, and dupes resolve the map right after
the session discovers the workspaces, before the analysis, so a map error fails
fast. Git resolves each distinct ref once per run.
The scope always narrows the final result: the last scope filters run after
type-aware refinement and before baseline comparison and gates. check and the
programmatic dead-code runtime also narrow before refinement to save sidecar
work. They apply every scope filter again after refinement, because refinement
can add findings such as private-type leaks. The filters only remove findings,
so the second pass is idempotent for the findings that the first pass kept. The
editor narrows once, after refinement.
The global --no-package-baselines flag sets
ChangeScopeRequest::no_package_baselines, so the run never reads the map and
reports every package in full scope; the programmatic option, the MCP
parameter and the LSP initialization option packageBaselines: false set the
same field. FALLOW_PACKAGE_BASELINES=false empties
ResolvedConfig::workspace_changed_since at config load, in the engine for the
LSP, MCP and Node hosts and in runtime_support.rs for the CLI. A narrowed run reports the map as the
package-baselines scope reason, separate from changed-since, so a consumer
knows that dropping --changed-since does not widen it.
The saved baselines differ on purpose. check compares and saves its baseline
after the scope, so a baseline saved under the map is partial, records the
package-baselines scope reason, and prints a warning that names
--no-package-baselines. The file records the saving run's scope_reasons;
a later run that lacks one of them prints a warning before the comparison,
because it can report findings outside the saved scope as new. dupes compares its
baseline with the report before the package map narrows it, so the baseline
sees every clone group and no scope reason is recorded.
For example, .fallowrc.json can contain
"workspaces": { "changedSince": { "packages/web": "main" } }. JSON check,
dead-code, dupes, and combined reports include optional
package_baselines provenance when the map applies; SARIF, CodeClimate,
compact, Markdown, and badge output do not carry it. Each row has an exact
project-relative workspace_root and its reference. The rows list the
applied map, including packages that a --workspace scope does not report.
The field is absent for unconfigured runs, a global ref override, a map that
stood down, and health, security, and audit reports. The LSP publishes
the same row type in fallow/analysisComplete.
The production mode of each analysis is
fallow_engine::project_config::ProductionFlags. The error-severity rule is
fallow_engine::error_severity. The dead-code baseline loader is
fallow_engine::baseline::apply_dead_code_baseline. The editor complexity lens
is fallow_engine::health::inline_complexity. In the CLI, crate::gates
builds every gate_outcomes entry, and crate::exit_codes::gate_exit_code
maps a gate verdict to the exit code.
Exit 1 on an analysis command means that an enforced gate failed. A config
load warning never sets it. A workspace diagnostic such as
node-modules-missing or a broken tsconfig extends never sets it either.
The exceptions are source-parse-degraded and source-nesting-too-deep, which
fail the run only through the opt-in --fail-on-parse-error gate. Machine consumers read
gate_outcomes. Under --quiet and in every format except the human report,
crate::gates::print_exit_reason prints one stderr line that names the failed
gates, with observed and threshold when they exist. The text comes from
report::gate_outcome_text::exit_reason_line. The line leaves out the
parse-error, stale-baseline and baseline-growth gates, because each of
them prints its own line in every mode. The own_lines member of
crate::gates::ExitReason names the other gates that printed their own line in
this run, for example the regression outcome when the run is not quiet. The
line does not repeat them. A caller that owns the exit code, such as audit,
sets exit_reason: false on the section print options. The bare combined run
also sets it to false on each section, and prints one line for the whole run
after every gate set the exit code.
A bare machine run without --fail-on-issues publishes the
FAIL_ON_ISSUES_GATES entries with enforced: false and exits 0 on them.
When such a run exits 0 and one of these entries fails, the combined printer
prints a [W] Exit code 0: line from
report::gate_outcome_text::unenforced_gates_line. The line names the gates,
the --format value and --fail-on-issues. It is printed also under
--quiet. A run that exits 1 for another gate does not print it, because the
line states exit code 0.
crates/cli/src/audit.rs: the CLI runners and the review brief of the changed-code audit. The audit itself (base snapshot, rename remap, attribution, dependency scope, verdict) iscrates/api/src/audit_run/, whichfallow audit, the MCPaudittool andfallow_api::run_auditshare.crates/cli/src/base_worktree.rs: temporary base snapshots and cleanup.crates/cli/src/check/: dead-code filters, severities, workspaces, and baselines.crates/cli/src/report/: human and machine-readable rendering.crates/cli/src/fix/: dry-run plans and confirmed mutations.crates/cli/src/agent_install/: one-pass agent onboarding (fallow agent), composed oversetup_hooks.rsandinit.rs;build.rsembeds the shipped skill for installs withoutnode_modules/fallow.crates/cli/src/doctor.rs: human and JSON rendering plus exit semantics for the read-only readiness report assembled bycrates/api/src/doctor.rs.crates/cli/src/coverage/andlicense/: runtime coverage and license command orchestration.crates/cli/src/viz/mod.rs: the self-contained HTML map, plus the DOT and Mermaid text renderings of the import graph.crates/cli/src/telemetry.rs: local opt-in telemetry state and spooling.crates/cli/src/cli_impact.rsandimpact.rs: local Impact history, attribution, aggregation, and the status-bar surface.crates/cli/src/runtime_support.rs: shared config and ownership helpers.
-
Dead-code
finding_idvalues are owned byfallow_types::identity. The engine pipeline (run_engine_owned_dead_code_pipelineincrates/engine/src/session.rs) stamps them once, after the detectors and beforeignoreFindings, the scope filters, baselines and rule severities. The CLI, LSP, MCP and napi reach dead-code results only through that pipeline. Type-aware refinement runs after the scope filters, so it callsstamp_missing_finding_ids: it keeps each existing id and gives an id only to a finding without one. Do not restamp a filtered set, because the~ktiebreak suffix depends on the other findings with the same subject. The same module owns the FNV-1a 64 helpers that CodeClimate and SARIF fingerprints and security ids use; do not add another copy. -
A dead-code SARIF result has three
partialFingerprintskeys:tools.fallow.fingerprint/v1andprimaryLocationLineHash/v1(rule, URI, normalized snippet and column; GitHub code scanning reads the second) andfallowFinding/v1(thefinding_id).append_sarif_findingsincrates/output/src/sarif.rswrites the third key, and only for a finding that gives exactly one result. The fan-out helpers for unlisted dependencies and duplicate exports do not write it.ensure_unique_result_fingerprintsrewrites only the two location-based keys. Do not change their inputs: a change reopens every GitHub alert. -
The canonical key of a dead-code finding (
IdentifiedFinding::canonical_keyinfallow_types::identity) is the readable input of itsfinding_id:<rule>:<path>:<name>..., never a line or a suppression reason. The dead-code baseline (crates/engine/src/baseline/mod.rs) and the audit new-only keys (crates/api/src/audit_keys.rs) use only this key, so the id, the baseline and the audit cannot drift. Do not build a dead-code key by hand.- A saved baseline carries
"identity": "dc1", stores the key once for each occurrence, and matches by count. A baseline withoutidentityis a legacy file: the legacy filter matches each old entry exactly, and the legacy key builders stay only as test helpers. A legacy load prints a stderr note in human output and setsbaseline_staleness.format: "legacy"in JSON. It never fails the run. - The audit numbers repeated keys with
dead_code_occurrence_keys(:~1,:~2, in collection order), so the base and the head compare by count and the rename remap still sees the path as its own segment. An unlisted-dependency finding is the package, not an import site, so a new import site of a package that the base already reports stays inherited. A change to the audit key form must bumpAUDIT_BASE_SNAPSHOT_CACHE_VERSIONincrates/cli/src/audit_cache.rs. Each option that changes what the base pass puts in the snapshot must be part of the cache key. For example, only a--briefbase pass computes the public-export set, sobriefis a key field.
- A saved baseline carries
-
The unused-file cascade filter is
fallow_engine::dead_code::hide_cascade_findings. It runs insideapply_rule_severities, so the CLI, the programmatic API, MCP, the Node bindings and the editor diagnostics hide the same set.fixcalls it directly, becausefixruns no rule pass. The filter removesunused_exports,unused_types,unused_class_membersandunused_enum_membersfindings only when the report lists their file inunused_files. Other finding kinds in an unused file stay.- An inline
fallow-ignore-file unused-filecomment removes the file from the report, and the filter still hides the export and member findings of that file. The detector records such files inCascadeState::suppressed_unused_files. A suppression comment therefore never adds a finding (drift invariant I6). - An issue-type selection without unused files, a scope, an
ignoreFindingspattern or anoffrule that removes the file from the report hides nothing in that file. The issue-type filters clearsuppressed_unused_filestoo. The CLI therefore runs the issue-type filters before the rule pass, as the programmatic runtime does. - With
--changed-sinceor--workspace,cascade_hiddencounts only the findings in the scoped files, because the scope runs before the filter. - The removed findings move to
CascadeState::hidden, andAnalysisResults::cascade_hiddencounts them. - SARIF writes the count as
cascadeHiddenin the runproperties(fallow_output::dead_code_sarif), and markdown adds one note line (fallow_api::build_markdown). Both are absent when the count is zero. CodeClimate, compact, GitHub annotations and grouped markdown do not carry the count. - The grouped envelope copies
cascade_hiddento its root, as it does withunused_load_data_keys_global_abstain. The groups are new result sets, so no group carries the count. - The baseline runs after the filter. A baseline entry for the unused file
does not make its findings visible again. A baseline entry that matches a
hidden finding counts as matched in
baseline_staleness, andbaselines prunekeeps it. A baseline saved while findings are hidden does not contain them. - A regression baseline (
--save-regression-baseline) saved by an older version counts the hidden findings. After the upgrade it has more headroom, so users must save it again. fallow_engine::health::shared_parse_data_from_artifactsrestores the hidden findings before the health pipeline reads the dead-code results. The vital signs, the file scores and the score therefore count them in barefallowand in standalonehealthalike.--show-cascade, theshowCascadeconfig key and the MCP and Nodeshow_cascadeoption turn the filter off.
- An inline
-
dead-code --finding-id <id>(repeatable or comma-separated) reports only the requested findings.fallow_engine::dead_code::FindingIdFilterowns the syntax check and the filter;FindingIdTraceowns the evidence. The CLI (execute_check) andfallow_api::run_dead_code_with_baselineuse the same two types, so the answer is equal on every surface (drift invariant I11). The order is fixed:- The trace records the requested ids on the full result set, before the scope filters.
- Scope, issue-type filters and rule severities run. The requested ids that
disappear here go to
filtered. - Type-aware refinement runs outside a stage: a finding it removes is gone, not filtered.
- The baseline runs as a second filter stage.
- Regression and the baseline save see the set before the id filter.
- The id filter runs last. The SARIF side file, the JSON envelope and the error-severity exit code see only the requested findings.
-
The JSON envelope carries
finding_id_queryonly when the run received ids:requested,found,missing,filtered,conclusiveandinconclusive_reasons.conclusiveis false when the run used a scope channel (the same set as the baselinescope_reasons: diff,--changed-since,--workspace,--changed-workspaces, a positional path,--file, an issue-type filter, production mode,includeEntryExportsfrom the flag or the config),--baseline, when the rule of a missing id isoffinrulesor in anyoverrides[].rules(rule-off), or when a requested id was filtered (filtered). Production mode counts whether it comes from the flag or from the project config, so a project withproduction: truein its config never gets a conclusive answer. A missing id underconclusive: falseis unknown, never resolved. -
finding_id_query.analysis_fingerprint(af1:<16 hex>) isfallow_engine::dead_code::analysis_fingerprint. It hashes the fallow version,ResolvedConfig::detection_config_digest(the merged user config afterextendswithout the keys inNON_DETECTION_CONFIG_KEYS, plus the loaded external plugins and rule packs, all as canonical JSON with sorted keys), the settings a surface changes after resolution (production mode,includeEntryExports,showCascade, the effective rules, type-aware mode and requirement, type-aware project list, the file size limit) and the root-relative path and normalized content (CRLF to LF, trailing newlines removed) of these files:- every
.gitignoreand.ignorethe walk reaches, and.git/info/exclude; - every
package.json,tsconfig*.jsonandjsconfig*.json, plus the files a tsconfigextendschain names (relative, or a package under the rootnode_modules), also outside the walk; - every file that matches a config pattern of a built-in plugin
(
fallow_core::plugins::registry::builtin_config_patterns) or of an external plugin, whether or not the plugin is active this run.
The approach is a declared file set, not tracking of the files the resolver and the plugin registry open: tracking would thread a recorder through the resolver and every plugin. The set is a superset of what a run reads, so the error is a false "unknown", never a false "resolved". The walk ignores the global git excludes file (
git_global(false)), skips hidden directories (the fallow cache lives there),node_modulesandignorePatternsmatches. Known exclusions: the global git excludes file and other machine environment outside theFALLOW_*variables. Source files are not inputs, so a source edit keeps the value; a manifest or tsconfig edit changes it, also when the edit fixes a dependency finding.FindingIdTrace::finishcomputes it from the resolved config, so every surface gives the same value. - every
-
A consumer stores the fingerprint with its verdict. A later query with another fingerprint is unknown, even when
conclusiveis true: a config, ignore file, plugin or version change can hide a finding that still exists, and no reason list can see a change between two runs. -
A missing id under
conclusive: trueand an equal fingerprint means "fixed, suppressed, or ignored by config", never "unknown". An inline suppression comment or anignoreFindingsentry hides a finding because a person chose to hide it, so the finding counts as absent. A consumer that must tell a fix from a suppression reads the suppression state separately. -
The exit code follows the normal rule: 1 when a reported finding has error severity, 0 when every requested id is missing. There is no separate exit code for a missing id. A malformed id exits 2, because a typo must never read as "resolved".
-
Health tie ordering and duplication collision handles are owned by the engine. Renderers, trace lookup, suppressions and baselines must use the same assigned handles. Preserve the collision migration contract when changing report identity.
-
Resolve user-provided file inputs against the user's project root before an audit switches to a base worktree. Prefix values such as
--coverage-rootremain absolute prefixes and must not be reinterpreted as input files. -
Audit base coverage attribution (#2347): the base-worktree pass scores from the same head-generated Istanbul map as the head pass, for explicit and auto-detected coverage alike. When no
--coverage-rootwas given, the canonical head project root becomes the strip prefix so every recorded path rebases onto the base worktree; an explicit prefix is forwarded unchanged. Relocated base lookups (coverage_relocated) tolerate unbounded line drift only when every same-named entry in the file agrees on one value. Consequences: complexity growth in files the map reports as untested attributes as inherited once the base function also exceeds the threshold, and test deletions cannot surface asintroducedcomplexity findings (the base is scored with the post-deletion map); coverage regressions belong torules.coverage-gapsand health trends. The auto-detected map's content participates in the base-snapshot cache key exactly like--coverage. -
New-only estimated CRAP: under
--gate new-only,audit_run::attributecallsdemote_estimated_crap_gates(crates/api/src/audit_complexity.rs) before the comparison. A complexity finding that exceeds only CRAP, withcoverage_sourceestimatedorestimated_component_inherited, getseffective_severity: warn. The finding stays in the report, and SARIF, GitHub annotations and the verdict read the gate severity from that field. A finding that also exceeds cyclomatic or cognitive complexity keeps the severity of its rules. Measured coverage (istanbul) keeps the gate. Inexceeded_metric_increased, a CRAP score from estimated coverage does not make a finding introduced, and a base finding without a CRAP score is not growth.--gate allkeeps the rule severity. -
Audit test-file complexity: under every gate,
audit_run::attributecallsexempt_test_file_gates(crates/api/src/audit_complexity.rs) before the comparison. It reusesfallow_engine::test_paths::is_test_pathon the path below the project root, so specs, test directories, end-to-end suites, mocks and fixtures match. Each such finding getseffective_severity: warnandgate_exemption: test-file. The attribution does not change. The finding stays in the report. SARIF putsgate_exemptionin the property bag at levelwarning, CodeClimate appends a note to the description, and the GitHub summary showswarn (test file). The human, markdown and compact views list the test-file findings that would otherwise take part in the gate (crates/cli/src/audit_complexity_notes.rs).fallow healthdoes not call this function. -
New-only growth of an exceeded metric:
ComplexityClass::of_match(crates/api/src/audit_complexity.rs) makes a matched finding introduced only in two cases. In the first case, the head finding exceeds a metric that the base finding did not exceed, and that metric grew. In the second case, a metric that the head finding exceeds grew, and the severity tier (moderate,high,critical) is higher than in the base finding. Other growth isworsened.ComplexityMetricsstores the exceeded flags, the severity tier and the coverage source of the base finding from the base run. Do not recompute them with the head thresholds, because ahealth.thresholdOverrideschange in the same PR would then make an old finding introduced. The tiers use the fixedDEFAULT_*_HIGHandDEFAULT_*_CRITICALlimits. CRAP takes part in the tier only when both sides have a measured score.audit.worsened: erroris the strict mode for teams that want every growth to gate.audit.worsened: offclears growth only in metrics that the finding does not exceed. Growth of an exceeded metric staysworsenedand gives at leastwarn(clear_worsened_for_off), because before this rule it made the finding introduced. The audit base snapshot cache is version 15. -
Audit coverage inputs (#2359) resolve through the same precedence as
fallow healthand barefallow:--coverage/--coverage-root, thenFALLOW_COVERAGE/FALLOW_COVERAGE_ROOT, thenhealth.coverage/health.coverageRoot, then auto-detection.fallow_api::coverage::resolve_coverage_inputsis the single, pure owner of that order (#2368);resolve_coverage_inputsincrates/cli/src/dispatch.rsreads the env vars at the CLI boundary, loads config lazily, and delegates, and the MCPaudit/check_healthtyped route delegates the same way. The resolved paths land inAuditOptions, so the head pass, the base-worktree rebase, and the base-snapshot cache key all see the same map. A configured path that does not exist fails audit with the same structured exit 2 as health, and a relative winning root is rejected before analysis starts. Resolution is scoped to runs that score health: bare combined mode resolves coverage only when--only/--skipkeep health in the run, sofallow --only checkneither loads config for coverage nor rejects ahealth.coverageRootit never reads. -
Audit base-ref auto-detection is engine-owned.
fallow_engine::repo_refs::auto_detect_audit_base_refis the single owner of the upstream / remote-default / local-branch order, and bothcrates/cli/src/audit_base_ref.rsand the typedauditanddecision_surfaceroutes incrates/api/src/runtime/delegate to it. The CLI previously kept a second copy with its own git probe, which is how the two routes drifted into disagreeing about the same repository (#2699). Do not reintroduce base-ref detection, or a git probe serving it, outsiderepo_refs; every probe there returns trimmed, non-empty stdout, because callers feed the values back to git as refs and compare them as paths. The short HEAD SHA and the base analysis root are engine-owned for the same reason:repo_refs::short_head_shaandrepo_refs::base_analysis_rootare the single owners, and the CLI, the engine vital signs and the typed routes call them.base_analysis_rootcompares real paths on both sides, because a caller can spell the root through a symbolic link while git reports the resolved top level (#2740). Unrelated CLI-local probes stay where they are: the base-worktree helpers incrates/cli/src/base_worktree.rsand the hook scaffolding incrates/cli/src/init.rs. -
A base analysis root that the base commit does not contain is a normal audit shape, not a caller error.
repo_refs::resolve_base_analysis_rootreports it, and the typedauditanddecision_surfaceroutes take an empty base snapshot for it, so a package added on the branch has everything under it attributed as introduced, matching the CLI (#2699). -
Audit worktree cleanup must be scoped to Fallow-owned paths and registrations. Never prune unrelated user worktrees.
-
ci reconcile-reviewandci post-reviewisolate provider lifecycle failures per fingerprint: a failed mutation blocks only the remaining operations of that fingerprint, and every other stale fingerprint is still resolved in the same run. Both commands reportapply_hint,failed_fingerprints, andunapplied_fingerprintsso a wrapper can name what was left for the next run. Neither command creates or mutates a provider review; the content-free "reviewed" row that follows a resolution reply is GitHub's own wrapper around a standalone review-comment reply, and no available endpoint avoids it. -
Review comments close with a
fallow-fingerprint:v3:marker. The dead-code fingerprint in it is the hash of thefinding_id(crates/api/src/dead_code_codeclimate.rs::dead_code_issue), so it holds no line.CodeClimateIssue::legacy_fingerprintkeeps the older line-based value in memory only (it is not serialized), and the review envelope publishes it per comment aslegacy_fingerprint. The CI comment and review renderers must take typed CodeClimate issues: a round trip through the CodeClimate JSON drops the legacy value.ci post-reviewandci reconcile-reviewread v1, v2 and v3 markers (extract_fallow_fingerprint) and treat an open lifecycle whose marker holds a comment'slegacy_fingerprintas that comment (envelope_legacy_fingerprints,reconcile_sets). Remove the legacy field and this matching one release after the v3 marker shipped. -
JSON mode emits structured errors on stdout and keeps progress off stdout.
-
Reported project paths remain relative unless an editor or protocol contract explicitly requires absolute paths.
-
Serialized lists and human output use deterministic ordering.
-
--complexity-breakdownis opt-in. Per-decisioncontributionsare omitted by default and cloned only when requested; CLI and MCP health options forward the same choice to the engine findings builder. -
fixremains preview-first. Non-interactive mutation requires explicit confirmation. -
fallow impact statuslinestays path-free, read-only, plain text, and epilogue-free. Its trend compares only whole-project scans. -
fallow doctorstays path-free, local, read-only, and epilogue-free. It may discover a trusted optional companion, but must not start it. Required readiness failures emit the complete report before exiting 2. Reports go to stdout; the command rejects--output-fileso diagnostics cannot create or replace a project input. -
New output fields must move schemas, generated TypeScript contracts, MCP, LSP, VS Code, GitHub Action, and GitLab consumers together.
-
New-only duplication demotion (issues #2164, #2220): under
--gate new-onlyan introduced clone group none of whose instances overlap an added line is demoted to inherited. Without an opt-in shared diff, the merge-base worktree diff decides (crates/api/src/audit_run/outcome.rs,demote_preexisting_dupe_introductions). The CLI and the programmatic runtime run the same function. When an opt-in shared diff (--diff-file,--diff-stdin, or$FALLOW_DIFF_FILE) is active, it has already filtered the head duplication report with the same added-line overlap predicate. Every retained clone group therefore vetoes demotion, so a shared diff can prevent a demotion but cannot produce a rendered shared-source demotion note. Demoted groups stay counted as inherited and are also reported throughattribution.duplication_demotedand a per-groupdemotion_reasonfield; human output names the deciding diff source in the demotion note. -
Audit diff-risk rules:
crates/api/src/diff_risk/computesweakened-tests,added-suppression,test-only-branch,diverged-clone-siblingandadded-dependencyinsideaudit_run::run, so the CLI, the MCPaudittool andrun_auditshare one result. The pass reads base text at the merge base through onegit cat-file --batchprocess. It does not use the base snapshot, exceptdiverged-clone-sibling, which reads the clone-group instance locations of the snapshot (dupe_groups, base paths, not remapped). A skipped base pass or--gate allturns that rule off. Rename sources are skipped, and a rename target reads its base text at the old path.removed-testsandassertion-dropreport a file only when the total over all changed test files also dropped. Anadded-suppressionfinding honors only a suppression that names its rule, so a new blanketfallow-ignorecomment cannot hide itself. The rule tokens are inNON_CORE_KINDS, so the dead-code stale-suppression pass ignores them. The briefweakening[]signals for tests, thresholds and suppressions are a projection of these findings (crates/cli/src/audit_weakening.rs). Do not add a second detector there. Only the removed-security-step scan of CI files is local to the brief. -
Brief test reach:
test_reachin the brief classifies each changed value export withModuleGraph::export_test_reach(crates/graph/src/graph/test_reach.rs). The check reads the existing test reachability index and stores nothing new. Only value references count. The order is a covered reference from a test file (direct), any covered reference (transitive), a reference that a closed factory mock removed (mocked-only), then file reach (noneornone-file). The mocked-only check replays the masks along each reference path: the mask step runs before the reachable step, and a CommonJS hop never masks. It runs only when masking isactive. Past the profile cap, masking iscappedand no row ismocked-only. The changed-export list comes fromchanged_exports_for_changed_pathsincrates/engine/src/module_graph.rs, which parses each changed file once for the search gap and test reach passes. The CLI keeps only the exports whose declaration overlaps an added line before these passes run, so a wide diff does not classify untouched exports. The HEAD analyses read the diff, and the brief reuses that read. Theframeworklabel needs a name inCONFIG_ONLY_EXPORT_NAMES(crates/core/src/plugins/mod.rs), a pluginused_exportsrule that names it for the file (recorded in the trace provenance), and a literal initializer. The coverage-gap note of an untested export uses the same mocked-only check. Thechangelabel of a row compares the export name with the export names of the file at the merge base of the diff (export_names_at_baseincrates/api/src/audit_run/base_files.rs, onegit cat-file --batchreader). A renamed file is read at its old path. When git cannot read the base file, the label falls back to the diff lines, and that fallback labels a one-line editnew.e2e_runnerin the JSON and the humannone-filereason come from the samehas_e2e_runnerfact. -
Audit dependency scope: a dependency-level finding (unused, type-only, test-only or misplaced dependency, unused catalog entry) is in audit scope only when the changeset touches the manifest or catalog file that declares it. The rule applies to the head run and to the base snapshot (
scope_dependency_findingsincrates/api/src/audit_run/scope.rs).--changed-sinceon the other commands keeps every dependency finding. -
Audit unprovable classes: a run does not report a dead-code class that it cannot prove. Today this is
unused_catalog_entrieswhile a workspace pattern directory is missing. The base snapshot is a checkout of committed files, so a gitignored workspace member (a cloned sub-repository) is missing there and present in the head. The base records these classes asAuditKeySnapshot::unprovable_dead_code, fromfallow_engine::unprovable::unprovable_dead_code_collections. It reads the same config predicate that skips the detector (has_missing_workspace_pattern_dirs).attributeadds the head keys of those classes to the base key sets, so the verdict, the attribution counts and theintroducedflags read them as inherited. An inherited base adds its classes to the set. A real new finding of such a class is also inherited in that run; the head run cannot tell it apart. -
Narrowing requests report their own fate (issues #2687, #2688). Two channels can be asked for and refused:
--changed-sinceand the opt-in shared diff. Both widen the report rather than failing the run, so the fact travels on the envelope asrequest_outcomesand not only on stderr, which--quietremoves entirely on the$FALLOW_DIFF_FILEchannel. Two rules keep it honest. The print is quiet-gated and the RECORDING is not, so the object is identical with and without--quiet; and the sentence the envelope carries is the same string the stderr line prints (ChangedFilesError::changed_since_message,DiffStandDown::message), so a log a human read and a report a script read cannot state different remedies. The diff outcome lives in a siblingOnceLockbesideSHARED_DIFF(crates/cli/src/report/ci/diff_filter.rs) rather than inside it: that cache's three states each carry a documented correctness argument, and a reporting concern does not belong inside a filtering decision. A diff that parsed but names no analyzable file reportsapplied, because the filter WAS applied over an empty scope.fallow auditrecords nothing: it exits 2 rather than widen, and it resolves its base ref through the non-printingcrate::check::try_get_changed_filesso the widening sentence cannot reach a run that produced no report. On the combined envelope the root is the only carrier, matchingworkspace_diagnostics. -
The object also carries
--sarif-file, which produces a file BESIDE the report rather than narrowing it, so every entry publishesaffects(scope/artifact) and every consumer selects on that rather than on a name. Without it the one sentence a consumer writes for the whole object reported a failed SARIF write as a run wider than requested, on the pull request, the merge request, the job summary and the Action'srequests-unappliedoutput. The class is derived from the name insideRequestOutcome::applied/not_applied, so an entry cannot be filed under one name carrying another's class. -
The stand-down sentences name their candidate bases by relation to the analysis root (
the project root,the repository root (the project root is <offset> below it)) rather than by absolute path, becausemessageis a wire field and every other path-bearing member of a fallow envelope is project-root-relative.requestedis the exception by design: it echoes what the user typed, which may be an absolute path they chose. -
fallow_engine::codeowners::CodeOwnersis the single CODEOWNERS matcher. Every owner, section and owner-count lookup (--group-by owner,--group-by section, ownership signals, coverage owner counts, audit ownership) uses it. The patterns follow gitignore semantics. A pattern without a trailing/matches a file or a directory, and a directory match covers all paths below it. Thus one rule can compile to two globs, andglob_rulesmaps each glob index back to its rule index. Uselast_matching_rulefor a new lookup. Do not read aGlobSetmatch index as a rule index, because last-match-wins compares rule indexes. The globs useliteral_separator, sodocs/*matches the direct children ofdocsonly. TheGlobSetmatches all rules in one automaton, and its lookup time grows with the number of wildcards in the file.CodeOwners::parserejects a pattern with more thanMAX_RULE_WILDCARDSwildcards and a file with more thanMAX_FILE_WILDCARDS. Large public CODEOWNERS files use fewer than 400. -
A stderr warning that repeats a
workspace_diagnostics[]entry, or that describes the analysis inputs, uses the tracing targetfallow_types::ANALYSIS_DIAGNOSTIC_LOG_TARGET(fallow::analysis_diagnostic).build_tracing_filterincrates/cli/src/cli_startup.rsturns that target off when the run is quiet (--quiet,--ciorFALLOW_QUIET) andRUST_LOGis not set. Use the target for a new warning of this type. Do not use it for a config-load warning, such as an unknown rule key, because a quiet run must still show those. -
ignoreFindingsalso covers parse degradations.record_source_parse_degradationsdrops the degradation of a covered file before the registry write and the stderr warning, so the envelope, theparse-errorgate and the health note see the same set.GraphConfidenceContext::for_runskips a covered degraded module, and that check records the pattern as matched. The skip also applies to a reachable module or an entry point, because fixtures that adynamicallyLoadedglob makes entry points are the common case. A covered file that is valid but fails to parse can hide the import that credits another module, with no caveat and no diagnostic. -
A dead-code baseline records the install state of the saving run in
analysis_identity.node_modulesthroughBaselineAnalysisIdentityincrates/engine/src/baseline/install_state.rs. The key is not part ofSemanticAnalysisIdentity, so a difference never stops the comparison. On load,check/baseline_notes.rsprints the mismatch also under--quietand stores thebaseline-install-state-mismatchdiagnostic onLoadedBaselineStaleness::install_state.complete_check_executionmerges it intoworkspace_diagnostics. The programmatic API adds the same diagnostic inapply_baseline.
health --group-by runs these steps in order:
-
The findings, hotspot and target stages apply
--topto the project lists. When--group-byis active and--topremoves entries, each stage also keeps the complete list (group_findings,GroupUntruncatedLists). Project targets use the truncated hotspots, as an ungrouped run does. Group targets use the complete hotspots. -
vital_data.rscomputes the project vital signs and loads the trend baseline once (load_trend_baseline, from.fallow/snapshots/or from--trend-from). The project trend compares against it. -
output_build.rsbuilds the grouping.bucket_pathsassigns every file, then the--groupselector removes buckets beforebuild_group, so the per-group vital signs and the duplication subset run only for kept groups. Parse, graph and churn work stay project-wide, because group metrics read project-wide signals. -
build_groupcounts severities and hotspots before it applies--topto the group lists, and returns the group vitals for the snapshot and trend. ThenGroupListSectionsempties each group list that the project report omits, with the same gates asassembly.rs. Thus a--scorerun keeps the score and counts of each group, but no group findings. The group duplication penalty counts each clone group with two or more instances in total and one or more in the group. The group counts only the lines and a share of the tokens of its own instances. Thus a clone that spans two groups lowers the score of each group, and the groupduplicated_linessum to the project value when the groups partition the files. Each instance carriestoken_count * (total - 1) / totalredundant tokens, rounded down, so the group token values do not exceed the project value.The three scoped duplication lenses use different rules:
health --group-bymeasures the lines of each group, as above.dupes --group-byassigns each clone group to the largest owner (the owner with the most instances), because it routes each finding to one owner. Seereport/dupes_grouping.rs.health --workspacekeeps only the clone instances inside the workspace and drops a clone group with fewer than two instances there. It measures copy-paste inside the workspace, not copy-paste across the workspace boundary.
-
apply_group_trendsmatches groups by key against the baseline, only for the samegrouped_bymode. A group without a stored entry isnew_grouponly when the storedgroup_filterkeeps its key. Otherwise the baseline run did not measure it, and the group getsno_group_baseline. -
maybe_save_health_snapshotsaves the snapshot with the group data. It runs after the baseline load, so a trend never compares a run with its own snapshot.
Markdown and the GitHub job summary render the group table through one JSON
renderer (fallow_api::build_health_groups_markdown), so report --from
on a saved grouped envelope gives the same summary as the live run.
The health envelope names the sections that the run produced in sections
(HealthSection in crates/output/src/health_report.rs).
produced_sections in crates/engine/src/health/assembly.rs builds the list
with the same gates that fill the report fields, so a token is in the list
exactly when the report carries that section. complexity means that
findings is the complexity list. Without it, findings is empty because
the run did not list the functions, and summary.functions_above_threshold
is the only count.
When a run does not list findings but summary.functions_above_threshold is
not zero, the complexity section gives that count and names --complexity.
It does not say that no function exceeds a threshold. One rule,
fallow_api::ComplexityListing::count_unlisted, decides this for both
renderers:
- A run whose
sectionsdoes not containcomplexitygives the count, also with a baseline. This applies to--score,--hotspots,--file-scoresand--targets. - A run whose
sectionscontainscomplexityand lists no finding is clean. With a baseline, the baseline accepts every finding. - An envelope without
sections(from an older fallow, read withreport --from) keeps the earlier rule: a run withoutvital_signs(score-only) gives the count, and another run gives the count only without a baseline.
The job summary renders only the complexity section, so it gives the count
for each run without complexity. Markdown gives the count only on a
score-only run. On --hotspots, --file-scores and --targets, Markdown
has no complexity section, as in the human output.
With a baseline, summary.baseline_staleness.remaining_findings is the
number of functions above a threshold that the baseline does not accept.
load_health_baseline in crates/engine/src/health/baseline_io.rs sets it
from the finding list after the baseline filter and before --top. It counts
functions, not baseline entries, because one function can match more than
one entry. The note (ComplexityListing::not_listed_note) then says "The
baseline accepts 2. 1 is new.". It asks for a --complexity run only when a
function is new. An envelope without the member keeps the earlier note: the
count includes the functions that the baseline accepts. dead-code and
dupes do not set the member.
fallow report --from <file> --format markdown prints the same bytes as the
live --format markdown run that wrote the file. The live markdown has no
elapsed time, so no field is masked. report::markdown::print_saved_markdown
reads the typed sections back from the envelope and calls the section printers
of the live run in the same order:
dead-codeandarchitecture: the findings, the type-aware evidence and the config patterns that matched nothing. A grouped envelope (dead-code-groupedorarchitecture-grouped) is flattened to its flat kind, sorted into the order of the analysis, and grouped again with the savedgrouped_bymode.cli_report::GROUPED_SAVED_KINDSlists the grouped kinds with their flat kind and current schema version.dupes: the duplication report. The live markdown of a grouped run is not grouped, so the saved render is not grouped.health: the fullHealthReport, then the## Health by <mode>section from the envelope, then the type-aware evidence.- the bare combined run: the
check,dupesandhealthsections in that order.
The full health report reads back because the health output types in
crates/output and the action types in crates/types derive Deserialize.
A field that a live run fills from a string constant uses the
static_str::StaticStr alias and a deserialize_with function. serde borrows
a field spelled &'static str from the input, and the alias stops that borrow.
Three cases are refused with exit 2 and a reason on stderr:
audit: the live audit markdown is the human report with markdown sections.securityandfix: these commands print no markdown document.- a combined envelope with type-aware evidence: the envelope keeps the evidence of the dead-code section only, but the live markdown also prints the evidence of the health section.
The combined envelope does not record --group-by, so the saved render of a
grouped combined run prints the dead-code section without groups and has no
## Health by <mode> section. The
report_parity_tests integration tests compare the two renders for each
supported kind.
Compact health output keeps the existing vital-signs: payload unchanged and
appends population rows immediately after it, in functions, modules, then
templates order:
cyclomatic-population:functions:count=1,sum=1,max=1
cyclomatic-population:modules:count=1,sum=31,max=31
cyclomatic-population:templates:count=0,sum=0,max=null
Each row mirrors the matching vital_signs.cyclomatic_population JSON group.
Counts and sums are decimal integers; max is a decimal integer or literal
null. A measured-empty group has count=0,sum=0,max=null. Older reports without
population metadata emit no population rows. Sum the group sums and counts to
reconstruct the cyclomatic mean; module scopes remain aggregate-only and do not
produce function findings. These rows describe metrics, not findings.
fallow viz runs one engine-owned project analysis with complexity artifacts
and the graph retained, then feeds fallow_engine::viz::VizData into a
self-contained HTML shell. render_html inlines the CSS, the frontend bundle,
and the payload into one file, so producer and consumer always ship together
and the payload carries no version of its own. The prebuilt TypeScript
frontend lives in viz-frontend/ and is embedded from
crates/cli/viz-assets/viz.js and viz.css; rebuild it with
cd viz-frontend && npm ci && npm run build rather than editing the bundle.
The payload travels in type="application/json" script tags, not as a
script literal. crates/cli/src/viz/payload.rs writes the core (files,
edges, summary and each availability) into #fallow-data, and each large
finding list into its own data-fallow-lazy tag. The per-file function
lists travel as one column that aligns with files. The frontend
(viz-frontend/src/payload.ts) parses a lazy section on the first read of
its property, so a lens that is not open costs no parse. The lens indexes in
buildIndex are computed on first use for the same reason. Arrays of
objects travel as {"$k", "$r"} tables to cut repeated keys; both sides pin
one fixture for this encoding. Code that the first paint runs must not read
a lazy section, or the deferral is lost.
The page body starts with a static frame from viz-frontend/src/shell.html,
which the frontend build copies to crates/cli/viz-assets/shell.html. It has
the page rows (top bar with the project name, toolbar, context strip, stage and
status line) with the ids in viz-frontend/src/shell.ts, so each row takes the
same box before and after the script replaces the frame. Keep counts and other
analysis text out of the frame: only the script derives them.
Invariants:
- Every analysis family carries an availability state (complete, disabled, not applicable, unavailable) alongside a unit-labelled count. A family that did not run must render as missing data, never as zero findings. Only the complete state licenses reading the count.
- The Health lens reuses the retained artifacts instead of reparsing. Churn,
hotspots, and ownership need a git-history walk viz does not perform, so
they report as unavailable rather than as empty. Runtime evidence needs a
runtime coverage input viz does not take, so the
runtimecapability reports unavailable with that reason instead of letting a complete static answer read as a complete one. - Coverage for the Health lens resolves through the shared precedence order described under the audit invariants, reusing the config viz already loaded. Viz has no coverage flags of its own, and auto-detection stays in the engine.
- The Security lens enables the advisory security rules through the shared
enable_security_ruleshelper and reports static candidates as candidates only. Runtime security stays explicitly unavailable without runtime evidence. - The rule and finding identifiers shown in the Security lens come from
fallow-security, the same source as the JSONfinding_idand the SARIFpartialFingerprintsvalue, so the surfaces join and cannot drift. --viz-format dotand--viz-format mermaidrender the import graph alone. They skip the Health run, the feature-flag pass, and the security rules, because none of that reaches their output.
fallow audit-cache maintains the reusable base-snapshot caches
($TMPDIR/fallow-audit-base-cache-*) that fallow audit builds and
garbage-collects. Two subcommands with distinct semantics:
audit-cache remove: delete every cache owned by an explicit--root, warm or not. Requires--rootand--yes(non-interactive), exits 2 on incomplete removal because it promises completeness.audit-cache prune: apply the same GC policy every audit run applies silently (orphaned-sidecar cleanup, age-based reclaim, cross-repo reclaim of abandoned entries), report every considered entry with sizes, and exit 0 whenever the command ran, including lock-contention skips and per-entry failures. Machine consumers gate on the envelope'scompletefield. Defaults to the current directory as root. A pre-#1815 registration at the current cache path is only deregistered and stays warm on disk: it reports as kept with reasonlegacy-deregistered. The envelope'sderegisteredfield is an informational subset ofkept, not a fifth member of theremoved + kept + skipped + failed == foundpartition. It also appears in the matching human summary line and never adds its size toreclaimed_bytes. Released SHA-keyed registrations are genuinely removed (reasonlegacy-registered) and stay counted as reclaimed.- Base-snapshot payload files: each audit base pass that misses the cache
writes
<cache>/cache/audit-base-v<N>/<key-hash>.bin, and the key folds in the base SHA and the changed files. After each save, audit removes the files that are older than the resolved threshold (measured from the last write or cache hit, because a hit refreshes the file mtime) and keeps only the newestMAX_AUDIT_BASE_SNAPSHOT_FILES(32) files. The cap applies also when the threshold is0. Prune reports these files withpass: "snapshot"and reasonsaged-out,over-capacity,freshorage-gc-disabled. It resolves the cache directory fromFALLOW_CACHE_DIR, thencache.dir, then<root>/.fallow(crates/cli/src/audit_cache.rs).
Shared invariants (crates/cli/src/base_worktree.rs):
- Both prune modes and the per-audit sweep share one decision code path
(
sweep_reusable_caches_with_report), so prune can never drift from what audits actually reclaim. --dry-runperforms zero filesystem mutation: no cache removal, no.locksidecar creation (acquiring a lock would create one), no.last-usedgrace seeding, and no git worktree deregistration. The legacy registered-cache pass is still enumerated read-only with the samelegacy-registered/legacy-deregisteredsplit an apply run reports..locksidecars are permanent lock identities and are never deleted: removing an unlinked-but-still-flocked inode while a racer re-creates the path would split one lock across two inodes.- Owner liveness for foreign entries is a NotFound-only probe on
std::fs::metadata: only a definitive NotFound classifies the recorded owner root as dead. Every other probe error (EACCES, EIO, ENOTDIR) keeps the entry asowner-unverifiable, so a transient failure can never reclaim a live repo's cache or defeat itscacheMaxAgeDays: 0policy. A path below an unmounted mountpoint still reads NotFound and is not protected. A dangling-symlink owner root resolves NotFound (dead). - Threshold precedence:
--max-age-daysflag, thenFALLOW_AUDIT_CACHE_MAX_AGE_DAYS, thenaudit.cacheMaxAgeDays, then the 30-day default.0disables age-based reclaim but still reclaims orphaned sidecars and dead-owner entries. - Per-entry GC diagnostics are debug-level tracing shared by the audit sweep
and prune:
RUST_LOG=fallow=debug fallow audit ...(or any prune run) emits oneaudit cache sweep considered entryline per candidate with path, pass, mode, decision, reason, age, threshold, and owner fields. WithRUST_LOGunset, audit stderr is unchanged. - Prune entry sizes come from a plain recursive walk that never follows
symlinks and deliberately ignores gitignore semantics: a cache entry is a
checked-out snapshot whose
.gitignorewould otherwise hidenode_modules, the bulk of the measurement.
fallow tracehas three targets.dispatch_trace_command(crates/cli/src/dispatch.rs) runstrace_path::run_trace_pathfor--pathandtrace_chain::run_tracefor aFILE:SYMBOL.--dependencygoes todispatch_trace_dependencyandtrace_dependency::run_trace_dependency.fallow trace --dependencyandfallow dead-code --trace-dependencybuild the baseDependencyTracewith one engine function,fallow_engine::trace::build_dependency_trace. The programmatic API uses it too. Do not add a fourth copy of the source, credit and manifest steps.- The dependency runner honors
--production,--no-productionand--workspaceasfallow dead-codedoes: the production mode goes intoload_config_for_analysisthroughProductionFlags::single_analysis_override, and the workspace scope is validated withcheck::resolve_workspace_scope. The scope does not narrow the trace. The--pathand symbol runners passproduction_override: None. - The usage walk (
crates/engine/src/trace_usage_impl.rs) readsModuleInfo.import_binding_references,imported_call_sites, imports, re-exports, dynamic imports andrequirecalls. It needs modules thatrelease_resolution_payloaddid not release, sotrace_dependency_with_sessioncallsanalyze_dead_code_with_shared_artifacts(true, true). - Only
jsonandhumanare supported. Human output goes to stdout throughoutln!;fallow dead-code --trace-dependencykeeps its stderr block. - A wrapper is an exported top-level declarator whose initializer is a
runtime binding of the package (
aliasshape) or a call on one (callshape). The walk follows one hop from each export name of the wrapper: named, default and namespace imports of the wrapper module. It also counts the uses of the wrapper in its own module (throughreferences from extraction) and each project re-export of the wrapper as are_exportsite withvia. It does not follow the consumers of that re-export. - A call wrapper returns a value. A call of a member of that value
(
store.dispatch()forexport const store = configureStore()) is anon_call_reference, not a call of the package export. The call that defines a call wrapper counts incall_site_count. - A wrapper of a whole namespace (
export const R = RR) takes the specifier of each consumer site from the first member (R.useSelector()givesuseSelector).
Start with focused CLI tests for the changed command. For output or schema changes also run:
npm run generate:contracts:check
cargo test -p fallow-cli
npm run verify:fastRun the matching format or integration review skill when a public rendering surface changes.