Skip to content

Latest commit

 

History

History
91 lines (86 loc) · 79.7 KB

File metadata and controls

91 lines (86 loc) · 79.7 KB
paths
crates/cli/**

fallow-cli crate

Key modules:

  • main.rs , CLI definition (clap) + command dispatch
  • error.rs , Structured error output (emit_error): JSON on stdout when --format json, stderr otherwise
  • audit.rs: Audit command (combined dead-code + complexity + duplication for changed files), verdict (pass/warn/fail). The public entry point run_audit absolutizes path-shaped INPUT FILE flags (--coverage) against opts.root before constructing the AuditOptions that flows into execute_audit. compute_base_snapshot swaps opts.root to a temp git worktree directory, so any new path-shaped INPUT FILE flag added to AuditOptions MUST resolve against the user's project root at run_audit rather than at the load site, otherwise downstream resolve_relative_to_root calls re-resolve against the worktree (which does not contain user-supplied files) on the recursive base pass. Pattern: let resolved = opts.<field>.map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root))); then thread the absolute path through a new AuditOptions { <field>: resolved.as_deref(), ..*opts }. Path-shaped flags whose VALUE is a prefix of paths INSIDE the input data (--coverage-root strips a prefix from Istanbul-data paths) are validated up-front via health::scoring::validate_coverage_root_absolute and reject relative values with exit 2 instead of being absolutized; absolutizing those would silently mismatch coverage-data paths. Cross-ref incident 2026-05-07 in ~/.claude/skills/fallow-review/skill.md. Worktree lifecycle (issues #472, #1815): BaseWorktree::create and reuse_or_create use WorktreeCleanupGuard<'a> (hand-rolled RAII, defuse(&mut self) after struct construction, idempotent) to roll back BOTH the on-disk dir AND git worktree registration on early-return paths between subprocess success and struct binding. Registration is now TRANSIENT (issue #1815): immediately after create_detached_base_worktree succeeds, unregister_worktree deregisters the worktree while KEEPING its directory, so the base-snapshot cache never appears in the host repo's shared git worktree list (IDE/GitLens/JetBrains clutter). unregister_worktree targets the single admin dir named by the <path>/.git gitfile pointer (gitdir: <host>/.git/worktrees/<name>) rather than a global git worktree prune, so a user's own prunable worktrees are never collaterally deregistered and git's name-collision admin suffixing (<name>1) is handled for free; the guard is defused right after this call (the entry is already unregistered, no early return follows). The <path>/.git gitfile is REPLACED with an invalid stub (gitdir: fallow-audit-unregistered), never deleted: both discovery walkers use ignore with require_git on, whose gitignore handling is gated on <root>/.git existing, so a missing gitfile would silently stop the base pass from honoring .gitignore (inflating base findings and skewing audit's introduced-vs-inherited split); the stub keeps gitignore parity while pointing at a nonexistent gitdir. Readiness is .sha-sidecar-based: reusable_audit_worktree_is_ready checks the cache dir exists AND a .sha sidecar (written strictly AFTER a successful materialization + deregistration, under the reuse lock) records exactly base_sha, replacing the old in-worktree git rev-parse HEAD probe (which read the host admin dir's HEAD, not snapshot content, so fidelity is equivalent). Pre-#1815 registered caches are migrated warm by try_migrate_legacy_reusable_cache (one last in-worktree git rev-parse HEAD seeds .sha, then deregisters in place). Non-persistent Drop is now remove_dir_all only (no git subprocess), so a SIGKILL never leaves an admin entry. reuse_or_create additionally acquires ReusableWorktreeLock (kernel flock(2) / LockFileEx via std::fs::File::try_lock, stable since 1.89) on <reusable_audit_worktree_path>.lock to serialise concurrent runs against the same base_sha; on contention the caller falls through to the non-reusable PID-named path (also deregistered right after add). process_is_alive now has a real Windows implementation under mod windows_process (target-gated windows-sys dep, OpenProcess + WaitForSingleObject with ProcessHandle(HANDLE) RAII for CloseHandle); ERROR_ACCESS_DENIED is treated as alive (conservative, mirrors Unix kill -0 EPERM). remove_audit_worktree emits tracing::warn! only when git worktree remove --force returns non-zero AND the dir survives, observable via RUST_LOG=warn. Any new code that mutates worktree filesystem state between git worktree add success and struct construction MUST use the guard, otherwise an early-return leaks both the dir and git's registration. Age-based GC (issue #498): execute_audit calls sweep_old_reusable_caches(repo_root, resolve_cache_max_age(opts), opts.quiet) (an Option<Duration> so the sweep runs unconditionally) at the top of every invocation. Since #1815 the reusable-cache enumeration is a repo-scoped temp-dir prefix scan (scan_reusable_cache_paths over fallow-audit-base-cache-{repo_hash:016x}-, the same hash reusable_audit_worktree_path computes), NOT git worktree list (which no longer sees the deregistered caches) and NOT a git-registered count: the old "NOT raw read_dir(temp_dir())" rationale inverts because the caches are no longer registered, and repo-hash scoping keeps one repo's sweep from reclaiming another repo's caches (which would defeat a sibling's cacheMaxAgeDays = 0 opt-out). The scan folds sidecar entries (.last-used/.sha/.lock) back to their owning cache path so a dir removed out from under its sidecars is still visited. A legacy pass runs FIRST (deregister_legacy_reusable_caches over list_audit_worktrees): it seeds .sha from HEAD and deregisters pre-#1815 registered entries (auto-cleaning the reporter's git worktree list backlog on the first post-upgrade audit), and the git worktree prune --expire=now is retained ONLY there (gated on having deregistered something) to also sweep any admin entry orphaned by a crash in the transient registration window. The primary pass removes unregistered entries whose .last-used mtime is older than max_age (directory + .last-used + .sha, no git subprocess). Sidecar-orphan reclaim: an entry whose cache DIRECTORY no longer exists (!path.exists(), an external $TMPDIR reaper / container restart / CI cache eviction deleted the dir but left its sidecars) has its .last-used/.sha reclaimed eagerly BEFORE the age branch, lock-guarded and re-checked under the lock against a concurrent rebuild; this runs even when max_age is None (age GC disabled via cacheMaxAgeDays = 0), so dead sidecars do not accumulate forever. This also fixes a pre-#1815 leak: a manual git worktree remove left .last-used/.lock sidecars the old git-scoped sweep could never see. sweep_orphan_audit_worktrees mirrors the same shape for the non-reusable PID-named path: a legacy list_audit_worktrees deregistration pass for dead-PID registered leftovers, then a global temp-dir prefix scan that remove_dir_alls unregistered dead-PID worktree directories. The age branch's sidecar logic only runs once path.exists() is confirmed, so the missing-dir case never re-touches a sidecar for a reaped entry. reuse_or_create touches the sidecar (<path>.last-used) on the cache-hit branch only; the fresh-create branch deliberately does NOT touch, because the pre-upgrade-grace path inside the sweep seeds the sidecar on the next invocation when absent (using dir mtime as a fallback would be wrong: POSIX dir mtime is the creation date, not last-use, so the fallback would wipe every legitimately-warm pre-upgrade cache on the first run after upgrade). Per-candidate ReusableWorktreeLock::try_acquire gates the removal; on contention the sweep skips the entry (an in-flight fallow audit mid-rebuild is preserved). The .lock sidecar is intentionally NEVER deleted by the sweep: an unlinked-but-still-flocked inode plus a racer's open(O_CREAT) at the same path would produce two processes holding kernel flocks on different inodes. The sweep emits a tracing::info! summary line on non-empty reclaim AND a stderr "fallow: reclaimed N stale base-snapshot caches" when !opts.quiet; per-entry removal failures emit tracing::warn!. Threshold resolution lives in resolve_cache_max_age: FALLOW_AUDIT_CACHE_MAX_AGE_DAYS env wins, then audit.cacheMaxAgeDays config field, then 30-day default; 0 from either source returns None (sweep disabled); invalid env values silently fall back to config / default.
  • base_worktree.rs: The current reusable audit cache identity is the canonical requested root only, not the base SHA. One root-owned path is rebuilt in place when its bounded full-SHA .sha sidecar differs. ReusableWorktreeLock stays held for the complete BaseWorktree lifetime so cleanup or a different-base rebuild cannot remove files under an active audit. On Unix, freshly materialized worktree roots are mode 0700, and a cache hit requires a real directory owned by the effective user with no group or other permissions. Sidecars must be owned regular files; creation uses create_new so predictable names never follow attacker-planted symlinks. Deregistration may remove only an admin directory under this repository's resolved Git common directory, with a matching gitdir backlink to the cache. Never trust the cache-controlled .git pointer based on its basename alone. fallow audit-cache remove --root <PATH> supports --dry-run; non-interactive mutation requires --yes, and lock-contended entries produce a partial report plus exit 2. The root-keyed pass still scans the released repo-hash plus SHA legacy shape for reclamation.
  • base_worktree.rs: The current reusable audit cache identity is the canonical requested root only, not the base SHA. One root-owned path is rebuilt in place when its bounded full-SHA .sha sidecar differs. ReusableWorktreeLock stays held for the complete BaseWorktree lifetime so cleanup or a different-base rebuild cannot remove files under an active audit. On Unix, freshly materialized worktree roots are mode 0700, and a cache hit requires a real directory owned by the effective user with no group or other permissions. Sidecars must be owned regular files; creation uses create_new so predictable names never follow attacker-planted symlinks. Deregistration may remove only an admin directory under this repository's resolved Git common directory, with a matching gitdir backlink to the cache. Never trust the cache-controlled .git pointer based on its basename alone. fallow audit-cache remove --root <PATH> supports --dry-run; non-interactive mutation requires --yes, and lock-contended entries produce a partial report plus exit 2. The root-keyed pass still scans the released repo-hash plus SHA legacy shape for reclamation.
  • Repository-root manual cleanup scans every current root-owned cache carrying that repository hash, including caches for nested roots that no longer exist. Automatic age-based GC still visits only the requested root plus released legacy entries, preserving independent cacheMaxAgeDays policy for sibling roots.
  • Repository-root manual cleanup scans every current root-owned cache carrying that repository hash, including caches for nested roots that no longer exist. Automatic age-based GC still visits only the requested root plus released legacy entries, preserving independent cacheMaxAgeDays policy for sibling roots.
  • check.rs , Analysis pipeline, tracing, filtering, output. check/output.rs::handle_trace_export resolves --trace FILE:NAME as a top-level export first; on an export miss it falls back to fallow_engine::trace::trace_class_member (issue #1744), which finds the class / enum / store export whose members contains NAME and reports the OWNER export's reachability + usage (reusing trace_export) plus a --unused-<kind>-members --file pointer, rather than erroring export not found. The trace path runs on the graph only (no AnalysisResults), so it does NOT report per-member crediting provenance. Trace JSON goes through the generic report::json::print_trace_json (NOT in docs/output-schema.json), so ClassMemberTrace needs no schema/TS-codegen regen.
  • dupes.rs: Duplication detection, baseline, cross-reference. ignoreImports defaults to true (issues #1224 and #1225): DupesOptions.ignore_imports: Option<bool> is the CLI/caller override (None defers to config) resolved by precedence (CLI > config > default) at the single build_dupes_config chokepoint, not OR-merge. The tokenizer excludes ES imports, re-export declarations, and top-level static CommonJS require binding declarations, while preserving local exports, side-effect or nested require calls, dynamic require args, and mixed declarations. Opt-out flags --no-ignore-imports (standalone) and --dupes-no-ignore-imports (combined, both resolved via resolve_ignore_imports in main.rs) are clap conflicts_with their opt-in pair and rejected by unsupported_security_global; print_ignore_imports_note emits a human-format-only stderr hint when module wiring was excluded and clone groups were reported (gated on DupesResult.ignore_imports + non-empty report). --trace (via run_clone_trace) accepts two address forms: FILE:LINE (clones at a location) and dup:<id> (a clone-group content fingerprint), dispatched on the dup: prefix to fallow_engine::trace::trace_clone_by_fingerprint vs trace_clone. The fingerprint is assigned by fallow_engine::duplicates::CloneFingerprintSet (xxh3 over the representative instance fragment, usually low-32-bit 8-hex, widened only on rare report collisions), surfaced in the human listing and on clone_groups[].fingerprint in JSON (via the CloneGroupFinding / AttributedCloneGroupFinding wrappers in output_dupes.rs). The trace renderer (report/human/traces.rs) shows per group a fingerprint header, an extract-function suggestion + estimated savings (deepdive::group_refactoring_suggestion), and a best-effort suggested_name (deepdive::dominant_identifier, None on generic/tie).
  • health/: complexity analysis. print_health_result owns the standalone fallow health exit-code gate (issue #786): --report-only short-circuits every quality gate to exit 0 after rendering; otherwise the score gate (--min-score), the findings gate, the runtime-coverage gate, and the coverage-gap gate are OR-combined. --min-score is authoritative for the complexity-findings gate, so when it is set complexity findings are demoted to informational and the exit code is driven by score >= N (making --min-score 0 always exit 0); --min-severity re-arms a severity-filtered findings gate and composes with --min-score; with no gate flag, any finding fails (back-compat). --report-only is rejected (exit 2) alongside --min-score/--min-severity in dispatch_health. Combined and audit callers pass report_only: false and min_score: None (they own their own gate semantics). mod.rs (orchestration + SubsetFilter for workspace/group scoping; health_action_opts computes report::HealthActionOptions from HealthResult::baseline_active and health.suggestInlineSuppression config to drive JSON suppress-line action emission and the top-level actions_meta breadcrumb), scoring.rs (file scoring + AnalysisCountsSnapshot for per-subset analysis-count recomputation), hotspots.rs, targets.rs, ownership.rs (bus factor, drift, declared owner cross-ref for --ownership), grouping.rs (per-group vital_signs / health_score recomputation for --group-by package|owner|directory|section, reuses SubsetFilter::Paths). CRAP-finding action selection in report::json::build_health_finding_actions is formula-aware: emit add-tests (tier=none) or increase-coverage (tier=partial/high) as primary ONLY when full coverage can clear CRAP (cyclomatic < max_crap_threshold); when cyclomatic >= max_crap_threshold (CRAP bottoms out at CC at 100% cov), drop the coverage action and emit refactor-function instead. When the finding carries an inherited_from field (synthetic Angular <template> findings whose CRAP was redirected to the owning .component.ts via the inverse templateUrl edge; see crates/cli/src/health/scoring.rs::build_template_inherit_contexts for the walker), build_crap_coverage_action overrides the coverage action: it emits increase-coverage with a target_path: "<owner>.component.ts" field and a description naming the component file, so AI agents add tests on the component rather than scaffolding tests against the structurally untestable .html template. The target_path field is schemarized on the typed HealthFindingAction struct in crates/types/src/output_health.rs so JSON consumers and the TS codegen pick it up. CRAP findings carry coverage_source; project summary and grouped health JSON expose coverage_source_consistency (uniform/mixed) whenever emitted CRAP findings have source data. health.crapRefactorBand (default 5) triggers a secondary refactor-function on CRAP-only findings whose cyclomatic is within the configured band, gated on cognitive >= max_cognitive_threshold / 2 to suppress false positives on flat type-tag dispatchers (high CC, near-zero cog). --churn-file (issue #980, global flag): imports change history from a fallow-churn/v1 JSON file (fallow_engine::churn::analyze_churn_from_file) instead of git log, so hotspots/ownership/targets work on non-git VCS (Yandex Arc, Mercurial, Perforce). hotspots.rs::fetch_churn_data branches on opts.churn_file BEFORE the is_git_repo check and returns a ChurnFetchResult whose since display is imported_since() = "imported churn" (the file is authoritative for the window; --since only labels output, both the ■ Metrics: line and the ### Hotspots header). The import reuses build_churn_result, so imported and git churn aggregate identically. A malformed file is a LOUD hard error (exit 2, single --format json document): mod.rs::validate_churn_file runs up front in BOTH execute_health and execute_health_with_shared_parse, gated on hotspots || targets (mirrors fetch_churn_data's needs_churn; --ownership is subsumed because dispatch sets hotspots = hotspots || ownership). The file is re-read in fetch_churn_data after the gate (cheap, bounded by MAX_CHURN_EVENTS); the gate owns the error, so fetch_churn_data's churn-file error branch only fires on a TOCTOU race and skips silently (no second error document). --complexity-breakdown: opt-in flag adding a per-decision-point contributions[] array to each complexity finding in --format json (the per-increment breakdown recorded by ComplexityVisitor, see .claude/rules/detection.md). Threaded through HealthOptions.complexity_breakdown into collect_findings + merge_crap_findings, which clone FunctionComplexity.contributions onto each ComplexityViolation only when set (default off keeps CLI/CI/SARIF/markdown output lean; gated at population, omitted via skip_serializing_if). Drives the VS Code inline editor breakdown; exposed on the MCP check_health tool as complexity_breakdown.
  • signal/ (issue #477): process-wide signal handling + scoped child-process registry. install_handlers() at the top of main() registers SIGINT/SIGTERM on Unix (signal_hook::iterator::Signals on a dedicated std::thread; the worker does a blocking sigwait so the body is regular Rust with no async-signal-safety constraints) or SetConsoleCtrlHandler on Windows (CTRL_C_EVENT / CTRL_BREAK_EVENT -> 130, CTRL_CLOSE_EVENT / CTRL_LOGOFF_EVENT / CTRL_SHUTDOWN_EVENT -> 143). ScopedChild is the RAII wrapper any long-running spawn site must use; it stores the Child handle locally and the registry stores only the PID (monotonic AtomicU64 key, NOT the recyclable Child::id()). The signal handler kills by PID side-channel (kill -9 <pid> shell exec on Unix, TerminateProcess on Windows) so the wrapper retains ownership for wait_with_output. Bounded drain budget: 500ms Unix, 1500ms Windows (the OS force-kills Windows console-control handlers after ~5s). One-shot guard via std::sync::Once so signal storms do not re-enter the lock. Adoption threshold: wrap any spawn whose expected wall time exceeds ~1s. Today: fallow-cov sidecar, npm install -g, self-invoked fallow health, git log --numstat churn analysis (via the fallow_engine::churn::set_spawn_hook function-pointer install at startup so core stays cli-independent), and git worktree add/reuse/remove audit operations. fallow watch opts into cooperative mode via GracefulModeGuard: the handler then only sets a shutdown flag, the watch loop's recv_timeout(200ms) polls it and returns ExitCode::from(0) because Ctrl+C is watch's documented termination path. Integration tests use a sub-process pattern via env!("CARGO_BIN_EXE_fallow") plus a FALLOW_TEST_SIGNAL_HELPER=1 env-gated helper subcommand, NOT self-signal-from-test-thread (which would take down sibling parallel tests).
  • watch.rs , File watcher with debounced re-analysis
  • fix/: Auto-fix. exports.rs, enum_members.rs, deps.rs, catalog.rs (line-aware YAML deletion of unused pnpm-workspace.yaml catalog entries; skips entries with non-empty hardcoded_consumers, rejects multi-doc YAML, reparses with serde_yaml_ng before persisting; per-instance auto_fixable flips computed in report/json.rs::build_actions), io.rs (low-level read_source returning Result<Option<(String, EncodingMetadata)>, EncodingError> with EncodingMetadata { line_ending, had_bom } and EncodingError::MixedLineEndings; pure classify_source helper reused by the staged-content fast path; bytes_with_optional_bom for whole-file rewrites in catalog.rs; re-export of fallow_config::atomic_write), plan.rs (issue #454 batch-atomicity layer: FixPlan accumulator, read_source_with_hash_check helper, SkipReason::ContentChanged | MixedLineEndings | LowConfidenceOffGraph | LowConfidenceUnresolvedImports, stage-then-rename commit via NamedTempFile). SkipReason::is_intentional() distinguishes the two low-confidence skips (intentional, exit-code-neutral) from the two recoverable ones. Each fixer takes &CapturedHashes, &mut FixPlan, reads source via read_source_with_hash_check to get (String, EncodingMetadata), splits on meta.line_ending, and passes &meta to stage_fixed_content so the UTF-8 BOM (EF BB BF) re-prepends on write when the source had one (issue #475). The orchestrator runs analyze_with_file_hashes (new fallow_core entry that returns AnalysisOutput with file_hashes: FxHashMap<PathBuf, u64> populated from ModuleInfo.content_hash xxh3, same hash the extract cache uses), threads the hash map through each fixer, and commits the plan in one batch. Hash mismatch (file changed between in-process analysis and write) skips the file, surfaces a Skipping <path>: file content changed since fallow check ran stderr line (gated on !opts.quiet), and contributes to a non-zero exit. Mixed CRLF/LF source is rejected up front (issue #475) with SkipReason::MixedLineEndings, surfaces a stderr line naming dos2unix and git config core.autocrlf input as remediation, and is NOT self-healing (re-running fallow alone does not normalize the file). Stage failure leaves every target file at its original content; rename failure mid-batch is reported per-path (POSIX has no atomic multi-rename primitive). The __target sidechannel field on per-fixer JSON entries correlates fixes to absolute paths for post-commit applied: false patching; it is stripped by strip_target_sidechannel before serialization. JSON envelope carries "total_fixed", "skipped" (catalog / YAML guard skips, semantics unchanged from pre-#454), "skipped_content_changed" (always present, hash-mismatch count, disjoint from skipped), "skipped_mixed_line_endings" (additive sibling, mixed-EOL skip count, also disjoint from skipped), and "skipped_low_confidence_exports" (issue #602, always present, disjoint from skipped; combined count of export removals withheld because the file sits under an off-graph consumer directory in exports::OFF_GRAPH_CONSUMER_DIRS (__mocks__, __fixtures__, fixtures, e2e, e2e-tests, cypress, playwright, examples, evals, golden) OR appears in results.unresolved_imports; per-record skip_reason is low_confidence_off_graph / low_confidence_unresolved_imports). The two low_confidence_* skips are INTENTIONAL: they do NOT contribute to the non-zero exit code (unlike content-changed / mixed-EOL); the export stays reported by fallow check for manual review. The gate lives in exports::apply_export_fixes and runs before the source read; only the export fixer gates (enum / dep / catalog fixers are unaffected). Human stderr emits a Run pnpm install to refresh pnpm-lock.yaml reminder after any successful catalog edit, a Skipped N file(s) that changed since fallow check ran reminder after any hash-mismatch skip, and a Skipped N file(s) with mixed CRLF/LF line endings reminder after any mixed-EOL skip. package.json and pnpm-workspace.yaml are NOT in file_hashes (extract does not parse them); the dep / catalog fixers re-parse those files at fix time as the natural safety net. config.rs (add_ignore_exports_rule for duplicate-exports config-add) keeps its own internal atomic_write path and is intentionally NOT batched (single-file, low-risk).
  • codeowners.rs , CODEOWNERS file parser, ownership lookup for --group-by owner
  • report/ , Output formatting: mod.rs (dispatch), grouping.rs (ownership resolver, result partitioning), human/ (check, dupes, health, perf, traces), json.rs, sarif.rs, compact.rs, markdown.rs, codeclimate.rs, ci/ (shared fingerprint/severity/diff-filter logic plus typed PR/MR comment and review-envelope formats). User-facing --format json passes JsonStyle to emit_report_json: compact by default, indented with global --pretty. SARIF, Code Climate, schemas, baselines, snapshots, caches, and other fixed or persisted JSON do not use this presentation switch.
  • report/suggestions.rs: command-level next_steps[] builder. Computes a small list of read-only, runnable follow-up commands (fallow_types::output::NextStep { id, command, reason }) from a run's findings, surfaced at the JSON root of the dead-code/health/dupes/combined/audit envelopes and as a one-line human Next: hint on bare fallow (TTY-only, mirrors the explain-tip gate via top_combined_next_step). Two contracts enforced by the next_step constructor's debug_asserts AND unit tests: every command is runnable as-is (no <...> placeholder) and never mutating (no fix/init/hooks/migrate/setup-hooks). Triggers (capped at MAX_NEXT_STEPS = 3, deduped by id, priority-ordered setup > impact-report > trace-unused-export > scope-workspaces > trace-clone > complexity-breakdown > audit-changed): setup (first-contact pointer; command is the read-only fallow schema BECAUSE init/hooks are banned MUTATING_VERBS tokens; gated by the caller-computed setup_pointer_applicable(root) = no config file up to repo root + !telemetry::is_ci() + !onboarding_declined), impact-report (at-most-weekly local value digest; caller-computed due_impact_digest(root) peek-and-stamps last_digest_epoch in the impact store, real counters in the reason, never CI), trace-unused-export (lex-min (path, name) finding for determinism), trace-clone (lex-min fingerprint), complexity-breakdown, scope-workspaces (only when discover_workspaces non-empty AND resolve_default_workspace_ref resolves a concrete ref via a self-contained git symbolic-ref/rev-parse --verify probe, else omitted to stay placeholder-free), audit-changed (gated on is_git_repo). The builders take offer_setup: bool + digest: Option<ImpactDigest> as PARAMETERS (env/fs probes stay at the call sites so builders are deterministic under test). Populated ONLY at the standalone/top-level chokepoints: build_check_output returns empty next_steps (so the combined/audit check sub-block built via build_check_json_payload_with_config_fixable stays empty), and build_json_with_config_fixable / build_health_json / build_duplication_json / print_grouped_json / print_combined_json / print_audit_json inject the array at the top level. audit emits only trace-unused-export + complexity-breakdown (no audit-changed/scope-workspaces; audit IS the changed scope) so it never spawns git/workspace probes (CI hot path). All builders no-op when !suggestions_enabled() (FALLOW_SUGGESTIONS=off) or the run is clean (zero findings), with ONE documented exception: a due impact-report digest may ride a clean run (a clean project after containment is exactly when the value report informs). Human counterparts live in combined/output.rs::print_failure_summary after the Failed: stderr line: a dimmed Impact: digest line and the SETUP_HINT line (deliberately NOT TTY-gated, agents reading piped human output are a primary audience; quiet gated by the caller). top_combined_next_step passes offer_setup: false + digest: None so the human Next: slot always shows an analysis follow-up. Additive-optional on the wire (skip_serializing_if = "Vec::is_empty"), never contributes to total_issues, no schema_version bump.
  • report/sink.rs: process-global Mutex<Option<BufWriter<File>>> sink (None = stdout) read by the outln!/out! macros that every report-CONTENT print site uses instead of println!/print!. --output-file/-o (global flag, valid with dead-code/dupes/health/security/bare, mirrors --sarif-file's command gate) makes main.rs::redirect_report_to_file open the file + set_file_sink + colored::control::set_override(false) BEFORE dispatch, so the rendered report (any --format) lands in the file with no ANSI codes; finalize_report_file flushes after dispatch and prints Report written to <path> on stderr (suppressed by --quiet). The sink is ambient so no *Options struct threads the path; programmatic/NAPI consumers use the build_* helpers and never set the sink, so they are unaffected. Interactive TTY chrome (the --explain tip in report/human/mod.rs, the combined orientation explain-tip in combined.rs) is gated on !report::sink::is_redirected() so it never pollutes the file. emit_json (the JSON/SARIF/CodeClimate/review chokepoint) routes through the sink, so all machine formats are covered by one site.
  • migrate/ , Config migration from knip/jscpd/stylelint
  • init.rs , Generate config files (.fallowrc.json or fallow.toml), scaffold starter agent guides (--agents), scaffold pre-commit git hooks (--hooks), record a deliberate stay-unconfigured decision (--decline, conflicts with the scaffolding flags; persists onboarding_declined in the impact store via impact::decline_onboarding, writes NO config file, suppresses the first-contact setup next-step + human Setup: hint)
  • list.rs , Show active plugins, entry points, files, boundary zones/rules (--boundaries)
  • viz.rs , viz command: one engine-owned project analysis (AnalysisSession::analyze_project_with_artifacts, complexity + graph retained) handed to fallow_engine::viz::build_viz_data, then emitted as a self-contained interactive HTML map (treemap + force graph with dead-code / duplication / boundaries / complexity lenses) or --viz-format dot/mermaid text. The HTML embeds the built TS frontend from viz-frontend/ (rolldown bundle at crates/cli/viz-assets/viz.js + viz.css, include_str!'d; rebuild via cd viz-frontend && npm ci && npm run build). Read-only, telemetry Workflow::ProjectInventory.
  • plugin_check.rs , fallow plugin-check read-only dry-run for external-plugin (manifestEntries) authoring. Loads config via fallow_engine::project_config::config_for_project, computes activation per plugin (is_external_plugin_active, empty discovered set + FS-fallback, no source walk), and for active plugins emits the shared RuleReport (fallow_engine::plugins::check_manifest_entries) as bespoke JSON with per-seed path_exists + typed warnings[]. Exit 0 always. See .claude/rules/plugins.md for the full loop.
  • schema.rs , schema, config-schema, plugin-schema, rule-pack-schema commands (the schema printers live in init.rs; rule-pack-schema prints RulePackDef::json_schema()). fallow schema is the agent capability manifest: manifest_version "1", clap-derived commands/global_flags, issue_types derived from the explain rule slices (one row per rule, all analyses; per-id metadata table for filter_flag/fixable/suppress_comment/note/license; drift tests pin completeness vs the rule slices, IssueKind coverage, suppress-token round-trip through IssueKind::parse, and filter-flag existence on live clap), an mcp_tools block from fallow_types::mcp_manifest, a live-derived plugins block, a task_matrix block (the agent task-to-command cheat sheet from crate::task_matrix::TASK_MATRIX, the same rows as init --agents, the hooks install --target agent managed block, root --help, and the generated SKILL.md section; drift tests parse every row's probe through live clap and pin the no-mutating-commands contract), and user-facing-only env vars (internal plumbing excluded via a guard test). Always JSON; ignores --format.
  • security.rs - opt-in fallow security command surfacing local security CANDIDATES (not verified vulnerabilities). MVP rule client-server-leak (detector in crates/core/src/analyze/security/mod.rs). run() loads config via load_config_for_analysis, forces rules.security_client_server_leak from off to warn (respecting an explicit user error), runs fallow_core::analyze, reads results.security_findings + results.security_unresolved_edge_files + unresolved-callee diagnostics, applies --workspace / --changed-since / --file retains, relativizes finding + trace paths, and renders SecurityOutput (human / JSON / SARIF). --file still builds the full project graph, then scopes candidates to matching finding anchors or trace hops and scopes unresolved-callee diagnostics by path. SARIF is hand-built at level: note with partialFingerprints (FNV) and no CWE; trace hops become relatedLocations. Exits 0 unless the user set the rule to error AND findings exist. SecurityOutput/SecuritySchemaVersion are a FallowOutput::Security variant registered in schema_emit.rs; --format json is in the published schema + TS contracts. Findings and unresolved-callee diagnostics are #[serde(skip)] on AnalysisResults, so they never appear under bare fallow or audit. Agent-actionable candidate record (issue #900): every SecurityFinding carries candidate { source_kind, sink, boundary } plus optional taint_flow { source, sink, path } and a top-level finding_id. Slot 1 source_kind is the stable catalogue source id, threaded through from tainted_sink::sink_source (it returns (id, title) now; the id was previously discarded after setting source_backed). The sink slot is self-contained (path/line/col/category/cwe/callee) and set by the detectors; the boundary slot (client_server from a ClientBoundary trace hop, cross_module from the reachability hop count, architecture_zone from the run's boundary-violation zone names) and taint_flow are filled by rank::enrich_candidate. rank_security_findings now takes a boundary_crossings: FxHashMap<PathBuf, (from_zone, to_zone)> map (built in analyze/mod.rs) instead of the old path set, so it can populate both crosses_boundary AND the zone slot. taint_flow.path is the compact { intra_module, cross_module_hops } shape; the full ordered hops stay on reachability.untrusted_source_trace, never duplicated. There is deliberately NO impact wire field (agent-owned, documented in the schema doc comment). finding_id is the SARIF FNV fingerprint extracted to the shared security_finding_id helper, stamped in run() after relativization so it equals the SARIF partialFingerprints value; relativize_finding also relativizes the candidate/taint_flow paths. export_visibility and a package boundary are reserved follow-ups (absent, not always-false). The current security schema version is V4 after metadata V3 and unresolved-callee diagnostic samples in issue #1134. secret-to-network exfil category (issue #890): an INCLUDE-REQUIRED catalogue category (CWE-201) admitted only via security.categories.include (gated by tainted_sink::is_include_required_category calling CategoryFilter::explicitly_admits, like hardcoded-secret). A non-public process.env / import.meta.env secret reaching a network sink's body/options arg via same-identifier source-backing. Three load-bearing pieces: (1) the new requires_source_kinds: Vec<String> matcher field narrows requires_source to specific source ids; matcher_admits_sink now takes the matched source as (id, title) (the call site stopped discarding the id) and gates on requires_source_kinds containing the id. (2) The shared is_public_env_var / is_public_env_path predicate moved to fallow_types::extract; the extract layer skips public env vars in tainted_source_path AND collect_source_paths_into (the latter must return BEFORE recursing into the bare process.env object, or the object prefix re-introduces the source, the bug that broke the #876 public-env regression). flatten_member_path gained a MetaProperty arm so import.meta.env.X is modeled as a source. (3) SinkSite.url_arg_literal captures the network call's arg-0 URL literal (call_url_arg_literal); for secret-to-network findings, tainted_sink builds candidate.network: SecurityNetworkContext { destination } (the literal host, or None for a dynamic destination, the suspicious case). CACHE_VERSION 135 -> 136. Hardcoded-secret-as-source and a provider-match heuristic are reserved follow-ups. --gate <mode> (issue #886, valued flag, new and newly-reachable; no all mode by design): an opt-in regression gate that reports ONLY candidates introduced on a CHANGED LINE and exits 8 if any exist, so a PR gates on new exposure without gating on the backlog. Requires a diff source (--changed-since, --diff-file, or --diff-stdin); a diff it cannot compute is a LOUD exit 2 (via changed_files::try_get_changed_diff, which returns Err on git failure rather than get_changed_files' silent None), NEVER a green gate. The gate filter is the STRICT check::filtering::retain_gate_new (a SEPARATE pass from the advisory filter_results_by_diff, leaving advisory display untouched): keep a new sink anchor on an added line OR a UntrustedSource/Sink trace hop on an added line; DROP the SecretSource && touches_file file-level exception and Intermediate/ClientBoundary pass-through hops (so editing a secret-reading file does not trip the gate). --changed-since for the gate builds a LINE-level diff (git diff --relative --unified=0 ref...HEAD); the existing --changed-since file-level filter is unrelated. Exit contract: 8 = new candidate (PURE: never "could not run"), and the gate SUPERSEDES the advisory --fail-on-issues exit-1 path in gate mode (composing would re-gate the backlog). The gate block on SecurityOutput (mode/verdict/new_count, snake_case, emitted on pass too so consumers distinguish "gate ran and passed" from "gate did not run") is additive on SecuritySchemaVersion V2; human prose says "REVIEW REQUIRED" (not FAIL) with the unverified disclaimer; SARIF keeps level: note and rides the gate as a run.properties.fallowGate property. Canonical pre-commit recipe: git diff --cached --unified=0 | fallow security --gate new --diff-stdin (staged content; --changed-since checks committed HEAD). --gate newly-reachable requires --changed-since <ref>, materializes the base tree through the shared base worktree module, and compares reachable keys (security-reach:<rel_path>:<kind>:<category>) for findings with reachability.reachable_from_entry == true. It runs before changed-file advisory narrowing so unchanged sink anchors that became reachable through changed imports are not hidden. Diff-only inputs (--diff-file or --diff-stdin without --changed-since) exit 2 because this mode needs the base tree, not just added lines. Base reachable snapshots are cached under .fallow/cache/security-base-v<N>/ unless --no-cache is set.
  • Security subcommands are part of the CLI contract: security survivors joins raw fallow security --format json output with verifier verdict JSON, emits summary.unverdicted, and supports --require-verdict-for-each-candidate for complete-verdict CI gates; security blind-spots groups unresolved-callee diagnostics and accepts --file both before and after the subcommand.
  • ci_template.rs , ci-template gitlab subcommand: prints the bundled GitLab CI template, or with --vendor [DIR] writes the template plus bash helper files (3 ci/scripts/: comment.sh, review.sh, and the gitlab_common.sh they both source, plus 1 ci/gitlab-ci.yml = 4 files total) into a project so vendored pipelines avoid raw.githubusercontent.com at runtime. --force is required to overwrite files that differ. The bundled template paths under crates/cli/templates/ci/ are git symlinks to the canonical workspace ci/ sources, so contributors edit one source of truth; cargo package dereferences the symlinks into regular files so the published crate stays self-contained (Windows checkouts must run git config --global core.symlinks true per CONTRIBUTING.md). The bundled file list (GITLAB_FILES) is kept in sync with the for f in ...; do cp ... loops in the template by a unit test (gitlab_ci_template_for_loops_match_vendored_files).
  • ci.rs , ci subcommands for provider-aware review automation. reconcile-review reads a typed review-github / review-gitlab envelope, loads existing provider comments/discussions once, computes new/stale fingerprints, and optionally posts resolution comments or resolves stale review threads. Keep provider API state loaded during planning threaded into apply; do not re-fetch between plan and apply. Fingerprint extraction goes through extract_fallow_fingerprint() which tries the v2 marker shape (<!-- fallow-fingerprint:v2: <fp> -->) first and falls back to v1 (<!-- fallow-fingerprint: <hash> -->) for historical backlogs; the v2-first order is load-bearing because the v1 substring is a prefix of the v2 marker, so v1-first would silently capture v2: as the fingerprint. The bundled action/scripts/review.sh + ci/scripts/review.sh mirror the same two-shape sed dedup pattern and accept any fallow-review-envelope/v<N> schema via test("^fallow-review-envelope/v[0-9]+$") so a consumer on an older bundled script keeps working when fallow bumps the envelope.
  • report/ci/review.rs , --format review-{github,gitlab} envelope renderer. Schema bumped to fallow-review-envelope/v2 (issue #528). Constants MARKER_PREFIX_V2, MARKER_SUFFIX_V2, MARKER_REGEX_V2, and MARKER_REGEX_FLAGS_V2 (all in output_envelope) must change together. The regex carries no inline flag group ((?m) is JS-incompatible); the m flag is emitted alongside in marker_regex_flags so consumers pass both to their engine. Render pipeline: group_by_path_line collapses consecutive same-(path, line) issues, then render_merged_comment emits one comment per group. Multi-finding groups get a merged:<16-char hash> composite fingerprint over sorted constituent fingerprints (identity shifts whenever the set of constituents changes, so the bundled wrappers' skip-if-fingerprint-exists logic correctly re-posts on content change). Single-finding groups keep the bare 16-hex v1-style fingerprint. constituent_fingerprints is NOT emitted on the wire; consumers that want update-in-place reconciliation track identity themselves via marker_regex. FALLOW_REVIEW_GUIDANCE=true appends per-finding collapsed "What to do" blocks from explain.rs rule guides inside the comment body; default-off output must stay byte-compatible. Body truncation at 65,536 bytes (conservative floor under GitHub's 65,536-char limit; GitLab is 1,000,000 chars per Note#note validation) walks back to the nearest UTF-8 char boundary; the closing fingerprint marker is preserved at the tail so reconciliation continues to work post-truncation. GitLab position.old_path is populated from DiffIndex.rename_pairs (parsed from rename from / rename to extended-diff headers when --diff-file or --diff-stdin is supplied); falls back to head-side path otherwise. render_review_envelope_with_diff is the test-shim signature that accepts a hand-crafted DiffIndex without touching the process-wide SHARED_DIFF cache.
  • config.rs , config subcommand: prints loaded config path + JSON resolved config (or --path only). Honors global --config <path>.
  • api.rs: shared HTTP layer for fallow-cloud backend calls. Exposes api_agent() / api_agent_with_timeout() compatibility wrappers, fallible try_api_agent() / try_api_agent_with_timeout() constructors that honor FALLOW_CA_BUNDLE, api_url() (respects FALLOW_API_URL), typed error-envelope parsing, actionable_error_hint(), http_status_message(), ResponseBodyReader, retry helpers for 429 Retry-After and 502/503/504, and the NETWORK_EXIT_CODE = 7 constant. Used by license/ (5s/10s timeouts), coverage/upload_inventory (5s/30s timeouts), coverage/upload_source_maps (5s/60s timeouts), explicit cloud runtime pulls in coverage/analyze, and provider CI API calls. FALLOW_CA_BUNDLE is a PEM file path resolved from the process cwd; it replaces ureq's default WebPKI roots with the certificates in that file, so corporate proxy users need a complete trust bundle.
  • license/ , license activate|status|refresh|deactivate subcommands. activate accepts JWT via positional arg, --from-file, or stdin (-); --trial --email <addr> issues a 30-day trial in one step. On Unix the stored license file is written with mode 0600. The trial response's trialEndsAt is surfaced on stdout after activation. status prints a refresh hint when the JWT's refresh_after claim has passed. refresh and --trial hit api.fallow.cloud via the shared api.rs layer; failures exit 7. Wraps fallow-license (offline Ed25519 verify, alg pinned, RS256/none rejected, 7/30/hard-fail grace ladder, optional refresh_after claim).
  • telemetry.rs: opt-in product telemetry (telemetry status|enable|disable|inspect [--example]). OFF by default; precedence DO_NOT_TRACK/FALLOW_TELEMETRY_DISABLED > FALLOW_TELEMETRY env > user config (<config-dir>/fallow/telemetry.json) > default-off, with CI forced off unless FALLOW_TELEMETRY is explicit. Payload is allowlisted and coarse (workflow, integration_surface, invocation_context, agent_source, output_format, quiet/ci/tty, os/arch, duration/exit-code buckets, optional failure_reason, optional findings_present, optional mcp_tool, sanitized parent_run); never paths, names, source, config, raw env, or errors. failure_reason appears only on workflow_failed events and is one of validation, unsupported_format, config, analysis, diff, network, auth, gate, signal, or unknown; known reasons are set only at explicit failure boundaries, otherwise failed workflows use unknown rather than parsing raw error text. agent_source is a fixed enum classified from explicit FALLOW_AGENT_SOURCE (allowlist) or a leading-word-boundary env-key heuristic (key_has_token); the env scan only runs when telemetry is On/Inspect. findings_present (Option<bool>) is set from each analysis's real result via the process-wide note_findings_present accumulator (AtomicU8 + fetch_max, OR semantics across combined-mode sub-analyses; assumes one analysis batch per process), decoupling "found something" from the exit-code outcome gate so informational analyses like default-config dupes (which never gates) are still measurable; absent on commands that run no analysis. Command::{Impact,Security,Fix,Explain} route to their own Workflow variants (not Unknown). integration_surface() honors the FALLOW_INTEGRATION_SURFACE override first (the MCP server sets it to mcp plus FALLOW_MCP_TOOL on the CLI it spawns, so MCP tool calls emit a single event tagged mcp + per-tool instead of cli_json); the mcp_tool value is allowlist-validated against the shared tool manifest (fallow_types::mcp_manifest::MCP_TOOLS). The Workflow/IntegrationSurface #[expect(dead_code)] stays fulfilled because the reserved in-process surfaces (lsp/vscode/napi/programmatic) and a few workflow variants remain unconstructed. Delivery is spool-based so the hot path never touches the network. Telemetry is recorded last (at process exit, once elapsed/exit_code are known), so at exit record_workflow / set_enabled (On mode) append the serialized event to telemetry-spool.jsonl (next to telemetry.json) via spool_event (lock-free O_APPEND, then trim_spool_if_oversized does a single fstat and only rewrites when the file exceeds SPOOL_MAX_BYTES=64KiB); flush_spool_in_background (called in main.rs right after setup_tracing, On mode only, no thread when the spool is absent) drains+POSTs the spool on a detached thread at the START of the next run, overlapping the analysis work so the upload is never on the critical path. The drain (drain_spool_file, generic over the poster for tests) flock-guards via SpoolLock (a never-deleted .lock sidecar, also taken by the trim so the two never rewrite at once), POSTs oldest-first, stops at the first POST failure (network-down short-circuit, so the removed set is always a prefix), drops corrupt non-JSON lines, then atomically rewrites (rewrite_spool, per-PID temp + rename) the undelivered tail capped to the newest SPOOL_MAX_EVENTS=64. Critical design point: the spool is bounded by the WRITE-path trim_spool_if_oversized, NOT by the drain completing, because on a fast command (sub-50ms analysis) the detached drain is abandoned mid-POST every run and its rewrite/cap never runs; an earlier rotate-to-.draining design grew the live spool unbounded and stuck a draining file in exactly that case (caught by the real-binary smoke). Errors are swallowed; delivery stays best-effort/lossy but a fast run now defers rather than drops its event, honoring the module's "never add meaningful latency" contract that the old recv_timeout(UPLOAD_GRACE_MS=200) grace-wait violated by ~50-200ms per run. Dispatched in main.rs before root validation via run_telemetry_command_if_requested; passive events recorded after post-parse setup and dispatch via record_workflow. The global --parent-run flag is hide = true until the correlation producer ships. Install grouping token (R5): TelemetryConfig.install_id: Option<String> is an anonymous, random, install-scoped token (new_install_id = SHA-256 over SystemTime + a RandomState-seeded u64 + pid + an AtomicU64, full 32-byte hex, inst_ prefix; reuses sha2, no new dep) minted ONLY in set_enabled(true) (via ensure_install_id) and lazily on the env-on send path (resolve_install_id_for_send -> testable pure resolve_install_id_with(mode, path), mints+persists when mode == On and a writable config dir exists, else None graceful fallback; the lazy mint persists ONLY the token, the config-level enabled flag stays default-off so an env-only opt-in never escalates into a persistent user-config opt-in). It is cleared to None on set_enabled(false) (disable = forget) and NEVER created or read when off / CI-forced-off / admin-disabled (the only mint sites are gated behind those). It is #[serde(default, skip_serializing_if = "Option::is_none")] so old telemetry.json files parse as None and never serialize a null; CONFIG_SCHEMA_VERSION stays 1. It rides the SAME out-of-band path as the parent-run token: sent as the private X-Fallow-Install transport header (INSTALL_HEADER) for server-side distinct_id grouping, NOT as an event property, so TelemetryEvent / example_event / field_purposes / TELEMETRY_SCHEMA_VERSION=2 are untouched. drain_spool_file's poster bound is FnMut(&Value, Option<&str> parent_run, Option<&str> install) -> Result<(), String>; the install id is resolved once at the flush_spool_in_background spawn site and threaded into the drain as a parameter, so unit tests never read the real env/config dir (PR #1198). telemetry status surfaces only install_grouping_token (presence boolean, never the token); inspect --example lists transport_headers(). The fallow-cloud side maps X-Fallow-Install to the PostHog distinct_id (validated against the exact mint shape, preferred over the parent-run token, installGrouped property marks the regime; deployed 2026-06-11).
  • update_check.rs: opt-out upgrade nudge for stale local human runs. The display path reads <config-dir>/fallow/update-check.json and prints one stderr hint only for human output with both stdout and stderr attached to a TTY, not quiet, not CI, and not suppressed by DO_NOT_TRACK, FALLOW_TELEMETRY_DISABLED, or FALLOW_UPDATE_CHECK=off. The background refresh fetches /v1/cli/latest-version through api_url() with tight timeouts, preserves the cache's disabled field, swallows all errors, and is mutually exclusive with the first-run telemetry opt-in note. Machine formats and agent paths must stay byte-identical.
  • coverage/ , Runtime Coverage subtree. Four subcommands today:
    • coverage setup: resumable first-run state machine (license → sidecar install → framework-aware recipe → auto-handoff to fallow health --runtime-coverage).
    • coverage analyze: focused runtime analysis. Local mode reads --runtime-coverage <path>; cloud mode is explicit only via --cloud, --runtime-coverage-cloud, or FALLOW_RUNTIME_COVERAGE_SOURCE=cloud, fetches /v1/coverage/{repo}/runtime-context, merges the cloud runtime facts with local AST/static analysis, and emits the same runtime JSON block. FALLOW_API_KEY alone must not select cloud mode.
    • coverage upload-inventory: POSTs a static function inventory to /v1/coverage/{repo}/inventory via the shared api.rs layer. Flags: --api-key (or $FALLOW_API_KEY), --api-endpoint, --project-id (default: $GITHUB_REPOSITORY$CI_PROJECT_PATH → parsed origin URL), --git-sha (default: git rev-parse HEAD), --allow-dirty (escape hatch: proceed with a dirty working tree even though the inventory then reflects the working copy rather than a SHA-exact commit), --exclude-paths (repeatable glob), --path-prefix (prepended to emitted paths for containerized deployments where runtime reports absolute paths like /app/src/foo.ts), --dry-run, --ignore-upload-errors (soft-fails only transport/server errors; auth remains fatal). Walks the project with fallow_extract::inventory::walk_source, emitting Istanbul/oxc-coverage-instrument-compatible names (per-file counter, bodyless functions and .d.ts files skipped). The current cloud join key is only (filePath, functionName), so the CLI rejects uploads when one file contains multiple distinct functions with the same emitted name. Server returns pathOverlap on the upload response; CLI prints a yellow warning when matched/sampled < 50%. Exit codes: 0 ok · 7 network · 10 validation · 11 payload too large · 12 auth rejected · 13 server error.
    • coverage upload-source-maps: POSTs JavaScript source maps to /v1/coverage/{repo}/source-maps via the shared api.rs layer so cloud-mode runtime coverage can resolve bundled paths back to original source files. Flags: --dir (default dist, scanned recursively), --include (default **/*.map), --exclude (repeatable, default **/node_modules/**), --repo (auto: package.json repository.urlgit remote get-url origin, parsed to owner/repo form, matching upload-inventory), --git-sha (auto: $GITHUB_SHA$CI_COMMIT_SHA$COMMIT_SHAgit rev-parse HEAD; empty-string env vars fall through), --endpoint (override base URL; otherwise FALLOW_API_URL then https://api.fallow.cloud), --strip-path (default true; emit basename as fileName. --strip-path=false emits the dir-relative path so monorepo bundlers reporting paths like assets/app.js still resolve), --dry-run, --concurrency (default 4), --fail-fast. API key is read ONLY from $FALLOW_API_KEY (no --api-key flag, intentional: keeps the secret out of argv). Repo is one URL-path segment, slashes are percent-encoded (owner/repoowner%2Frepo). Per-map retry: 3 attempts on network, HTTP 429, and HTTP 502/503/504; 429 honors Retry-After delta seconds and HTTP-date values capped at 60s. Map size: warn >10 MiB, reject >100 MiB. Exit codes: 0 ok · 1 partial-failure (some uploads failed, or HTTP/server failures) · 2 validation · 7 setup/transport failure that prevents every upload. Network I/O outside license is limited to explicit coverage-cloud commands (coverage analyze --cloud, coverage upload-inventory, coverage upload-source-maps); check/dupes/health stay offline.
  • explain.rs , Metric/rule definitions, JSON _meta builders, SARIF fullDescription/helpUri source, docs URLs
  • impact.rs: opt-in, local-only Fallow Impact value report (impact command with enable/disable/default on|off/reset [--all]/status subcommands; bare impact renders the report). The store lives in the USER config dir, NEVER in the repo (since v2.96): <config-dir>/fallow/impact/<project-key>.json per project plus <config-dir>/fallow/impact.json (the user-global default_enabled toggle), sharing telemetry::config_dir()'s base. store_path returns Option<PathBuf> (None = no config dir = inert). impact_project_key(root) derives the file key from resolve_git_common_dir (collapses all worktrees of a repo to one identity), falls back to the canonical root for non-git, case-folded on case-insensitive filesystems; project_identity(root) memoizes (project_key, worktree_key) per root (one git probe per run; fallow audit is perf-priority). Enabling writes NOTHING into the repo (the old ensure_fallow_gitignored call was removed from enable AND decline_onboarding). It surfaces three things: surfacing (current issue count from the latest record), trend (count delta vs the previous record), and containment (a gate-marked fallow audit run that exited fail then a later correlated run cleared it). record_audit_run is called from audit::run_audit after the verdict; both record paths early-return on telemetry::is_ci() (Impact is explicitly CI-off now, not just emergently) THEN on !resolve_enabled(&store).0, fully best-effort (load/save swallow all errors, never touch the exit code or output). resolve_enabled precedence: enabled == true is an explicit Project opt-in (wins even when explicit_decision is unset, so pre-explicit_decision stores never regress) > explicit per-repo disable (!enabled && explicit_decision, off as Project) > user-global default_enabled (User) > off (Default). ImpactReport carries enabled_source (project/user/default, registered as EnabledSource in schema_emit.rs); human status prints the resolved store path + project key (machine output omits them so the home path never leaks). On first load with no user store, migrate_legacy_store imports a pre-relocation in-repo .fallow/impact.json via the deserialize-only LegacyFlatStore (wrapping its flat frontier under the current worktree key) and leaves the legacy file untouched; monorepo subdir stores collapse to one repo key (pick-first). enabled lives in the store file, so per-project enabling = writing the file with enabled: true. The --gate-marker arg on audit is hide = true; the generated init --hooks pre-commit hook passes --gate-marker pre-commit and the Claude agent gate (setup_hooks/fallow-gate.sh) passes --gate-marker agent (the gate script's FALLOW_GATE_MIN_VERSION floor is 2.85.0, the flag's introduction version: older PATH binaries clap-reject the flag and would fail open, so they get the hard upgrade block instead; floor refs live in the script, three setup_hooks.rs tests, docs claude-hooks.mdx, and skills patterns.md). The store also carries onboarding/opt-in state: onboarding_declined (set by fallow init --decline, suppresses the setup next-step + Setup: hint), explicit_decision (set by BOTH enable and disable, so declining on a never-enabled project records "asked and said no"; mirrored on ImpactReport and in telemetry's config for the telemetry ask), and last_digest_epoch (peek-and-stamp cadence state for the weekly impact-report digest via take_due_digest; internal, never on the report). ImpactReport is a FallowOutput variant and its types (ImpactReport/ImpactCounts/TrendSummary/ImpactTrendDirection/ContainmentEvent/ResolutionEvent) are registered in schema_emit.rs so --format json is in the published schema + TS contracts. v1 reuses vital_signs::chrono_timestamp for record timestamps. v1.5 per-finding attribution (store schema_version 2, forward-compatible read of v1 stores): record_audit_run takes an Option<&AttributionInput> (built in run_audit from result.check/health/dupes via collect_dead_code_findings / collect_complexity_findings / collect_clone_findings). apply_attribution maintains a per-file frontier (line-independent finding IDs via fingerprint_hash(kind,rel_path,symbol) + present-suppression kinds) plus a fingerprint-keyed clone_frontier, and diffs them against the files the run re-analyzed (AuditResult.changed_files). Since the v2.96 user-store relocation, frontier/clone_frontier are nested FxHashMap<worktree_key, FxHashMap<rel_path, ...>> (store schema_version 4) so two worktrees of one repo (collapsed to a single store file by common-dir) do not prune each other's per-file baseline; apply_attribution pulls the current run's worktree sub-map into owned flat locals (FlatFrontier/FlatCloneFrontier), runs the existing flat logic against them, and re-inserts (dropping the worktree key when empty). The headline series (records/project_records/containment/resolved_total/suppressed_total/recent_resolved) stay flat/repo-shared. LegacyFlatStore reads the pre-v4 flat shape during migration. Each disappearance is classified resolved / suppressed (conservative: a covering suppression that newly appeared this run makes ALL same-kind disappearances suppressed, never a win) / moved (cross-file move cancelled within a run via a path-independent (kind,symbol) move-key; within-file line moves are free because the ID excludes line; CROSS-run moves are corrected by uncredit_cross_run_moves, which drops a prior-run resolution event + decrements resolved_total when its move-key reappears as a new finding in a later run, bounded by recent_resolved). The discriminator depends on AnalysisResults.active_suppressions (#[serde(skip)], populated by SuppressionContext::all_suppressions in analyze/mod.rs): it captures EVERY present suppression (all kinds, not only consumed) because complexity/code-duplication suppressions are consumed in the CLI layer, not the core context. New report surface: resolved_total, suppressed_total, recent_resolved: Vec<ResolutionEvent> (bounded MAX_RECENT_RESOLVED), attribution_active; human/markdown always render a RESOLVED section (three exhaustive states) with suppression as neutral "marked intentional" context, never a scoreboard. frontier is pruned to on-disk files each run. Attribution is a local-developer signal (accrues only where the store persists across runs, not ephemeral CI). Boundary-violation finding IDs forward-slash-normalize to_path so the hash is cross-platform stable. Not attributed in v1.5: multi-file dead-code kinds (circular deps, re-export cycles, duplicate exports, unlisted deps). Changed-file scope caveat: a fix that reverts a file to its audit-base state removes it from the changed set, so that resolution is not individually credited (under-count, honest direction); the human report and footer say so. v1.6 whole-project track: record_combined_run (called best-effort from combined.rs::record_combined_impact) appends to a SEPARATE ImpactStore.project_records series (kept apart from the changed-file records so the two scopes never share a trend). AttributionInput.changed_files was replaced by Scope::ChangedFiles(&[PathBuf]) | Scope::WholeProject; audit passes ChangedFiles(result.changed_files), the combined writer passes WholeProject, whose scope (via resolve_attribution_scope/whole_project_scope) is the union of the frontier keys + this-run finding/clone paths (NOT a module list; AnalysisResults has no modules field and changed_files is a git diff), so a clone or whole-repo cleanup fixed outside a changed-file audit is credited. The combined writer is gated airtight in is_whole_project_run: requires run_check && run_dupes && run_health, no changed_since/workspace/changed_workspaces, no active report::ci::diff_filter::shared_diff_index() (closes --diff-file/--diff-stdin, which are NOT CombinedOptions fields), and no production mode; it pulls active_suppressions from check_result so a suppressed-but-unchanged finding is credited suppressed not resolved. A reshaped clone (3->2 instances, still duplicated under a new fingerprint) is NOT credited resolved (classify_clone_disappearances skips a disappeared fingerprint whose files still participate in a current clone). ImpactReport gained project_surfacing / project_trend (additive optional; report schema_version stays 1). Human + markdown render an understated whole-project section with an "advances only on local full fallow runs, not CI" caveat. The project trend is a local-developer signal like attribution, not a CI/team metric. Cross-repo view (fallow impact --all): a read-only roll-up over every per-project store. --all is a flag on the impact command (rejected with exit 2 when combined with a subcommand, via a manual dispatch_impact guard not clap conflicts_with, since the subcommand is not a named arg), with --sort {recent,resolved,contained,name} (default recent, by latest_activity timestamp desc) and --limit N (caps printed rows only; totals always reflect every store). load_all() enumerates <config-dir>/fallow/impact/*.json (the global impact.json toggle is a sibling FILE one level up, naturally excluded), skipping corrupt/newer-schema files into unreadable_count (never ImpactStore::default()-substituted). build_aggregate_report reuses build_report per store, sums totals over ALL tracked projects (a deleted repo's past wins still count; staleness is deliberately NOT computed in the MVP, no path to stat), and EXCLUDES enabled-but-empty projects from projects[] (counted in project_count). The wire shape is a NEW FallowOutput::ImpactCrossRepo(CrossRepoImpactReport) variant (kind:"impact-cross-repo", independent CrossRepoImpactSchemaVersion) embedding the per-project ImpactReport verbatim per CrossRepoProjectEntry plus project_key (the store filename stem; folds in the project_key-on-json follow-up at the aggregate level, NOT on ImpactReport, which stays byte-identical) and label. The store gained an additive label: Option<String> (STORE_SCHEMA_VERSION 4 -> 5) = the git-toplevel BASENAME captured at record time (record_audit_run/record_combined_run/migrate) via repo_basename, NEVER a full path; project_identity now memoizes a (project_key, worktree_key, display_name) 3-tuple from one common-dir + toplevel resolution so the keys stay byte-identical (no store orphaning). JSON/markdown leak ZERO paths (label is basename-only, enforced by a no-separator unit test); human output adds one Stores: <config-dir>/fallow/impact/ line gated on is_human && !quiet. The 4 cross-repo types register in schema_emit.rs across the same 5 sites as any FallowOutput variant. Hardening (shipped after the cross-repo view): (1) both record paths take a blocking advisory lock around their load->mutate->save window via ImpactStoreLock::acquire(root) (kernel flock on a <store_path>.lock sidecar, mirroring telemetry::SpoolLock; best-effort None => proceed unlocked so a lock-layer failure never drops a record), so two worktrees of one repo (same store key) cannot lost-update each other. The .lock sidecar is NEVER deleted (an unlinked-but-locked inode + a racer's O_CREAT would split the lock). (2) Age-based GC: FALLOW_IMPACT_STORE_MAX_AGE_DAYS (registered in schema.rs::ENVIRONMENT_VARIABLES; resolve_store_max_age reuses base_worktree::days_to_duration, unset/0/invalid = no sweep) makes a recorded run call sweep_old_stores(keep_key, max_age), which deletes per-project <key>.json files whose FILE mtime is older than the window (any record rewrites the file via atomic replace, refreshing mtime, so an active repo never ages out), skipping the just-written project's own store, the .lock sidecars, and the sibling global impact.json toggle. The MCP impact_all tool (see mcp-server.md) wraps fallow impact --all. Still deferred: true staleness / --prune-stale (would need to re-persist an absolute path, undoing the relocation's privacy win; the age-based GC covers the accumulation need).
  • validate.rs , Input validation (control characters, path sanitization)
  • regression/ , Regression testing: tolerance.rs (thresholds), counts.rs (baselines), outcome.rs (verdict), baseline.rs (save/load/compare)
  • Regression baselines can be ratcheted in place with --save-regression-baseline and no value. This discovers the active fallow config and updates its regression.baseline counts, or creates .fallowrc.json when no config exists. Passing a path writes a standalone baseline file instead.
  • lib.rs , Library surface for the fallow-cli crate. Re-exports fallow_engine::codeowners, the explain, report, and error modules, and runtime_support::{AnalysisKind, GroupBy}. The binary (main.rs) owns clap + dispatch. The one-shot programmatic API (detect_dead_code, compute_health, etc.) moved to crates/api (fallow-api) in the architecture split; crates/napi depends on fallow-api only (zero fallow-cli edges in cargo tree -p fallow-node). The programmatic_common_options_track_analysis_affecting_cli_globals test in lib.rs keeps fallow_api common options in lockstep with analysis-affecting global CLI flags. The former programmatic.rs module and its AnalysisOptions::legacy_envelope are gone; the CLI --legacy-envelope flag was removed in v2.104.0 and tagged root envelopes (top-level kind) are the only wire shape.
  • runtime_support.rs , Shared build_ownership_resolver + load_config used by main.rs and the command modules, plus the AnalysisKind / GroupBy clap enums. Extracted out of main.rs so library consumers can reuse them without dragging in the full clap command tree.

Coverage input precedence:

  • Standalone fallow health and bare combined fallow resolve Istanbul coverage inputs independently as CLI flag, env var, config, then auto-detection: --coverage, FALLOW_COVERAGE, health.coverage; --coverage-root, FALLOW_COVERAGE_ROOT, health.coverageRoot.
  • The bare combined flags are intentionally non-global. fallow --coverage path dead-code must reject instead of silently ignoring the bare-mode input before a subcommand.
  • fallow audit keeps its own CLI/env coverage path and does not consume the health.coverage config fallback.

Environment variables

  • FALLOW_FORMAT , default output format
  • FALLOW_QUIET , suppress progress bars
  • FALLOW_BIN , binary path for MCP server
  • FALLOW_CACHE_MAX_SIZE , extraction cache (.fallow/cache.bin) cap in megabytes. Default 256. Wins over the cache.maxSizeMb config field. Resolved at runtime_support::resolve_cache_max_size_env; threaded into both CacheStore::load (size ceiling, max(max_size_bytes, DEFAULT_CACHE_MAX_SIZE) so a misconfigured tiny cap does NOT discard a valid existing cache) and CacheStore::save (eviction trigger). --no-cache short-circuits.
  • FALLOW_COVERAGE , path to Istanbul coverage data for accurate CRAP scores
  • FALLOW_COVERAGE_ROOT , absolute coverage-data prefix for CI or container rebasing
  • FALLOW_LICENSE , license JWT (full string). First-class storage path; intended for shared CI runners.
  • FALLOW_LICENSE_PATH , file path containing the license JWT.
  • FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS , clock-skew tolerance applied to the JWT's iat claim during verification. Default 86_400 (24h). A JWT whose iat is more than this many seconds in the future relative to the local clock is rejected as LicenseError::ClockSkew. Lenient parsing: unset / empty / unparsable / negative all fall back to the default so a typo in a runner env block does not fail license verification. Consumed by fallow_license::skew_tolerance_seconds_from_env(); threaded through verify_jwt_with_skew at all CLI license call sites.
  • FALLOW_COV_BIN , explicit override for the closed-source fallow-cov sidecar binary (wins over project-local node_modules/.bin, package-manager bin, ~/.fallow/bin/, and PATH). When set but the path is not a file, sidecar discovery fails fast with a targeted error rather than silently falling through.
  • FALLOW_API_URL: base URL for fallow cloud API calls (license refresh, trial, inventory upload). Trailing slashes are trimmed. Used for staging / local-dev overrides.
  • FALLOW_UPDATE_CHECK: set to off, 0, false, disabled, or no to disable the human-TTY upgrade nudge and its background latest-version check.
  • FALLOW_API_KEY: fallow cloud bearer token. Consumed by fallow coverage upload-inventory (flag --api-key wins) and fallow coverage upload-source-maps (env var only, no flag form, to keep the secret out of argv).
  • FALLOW_CA_BUNDLE: path to a PEM certificate bundle for fallow cloud and provider HTTP calls. Relative paths resolve from the process cwd. The bundle replaces the default WebPKI roots, so include public roots plus any private CA when needed.
  • FALLOW_PRODUCTION: global production-mode override for all analyses (true/false/1/0/yes/no/on/off).
  • FALLOW_PRODUCTION_DEAD_CODE, FALLOW_PRODUCTION_HEALTH, FALLOW_PRODUCTION_DUPES: per-analysis overrides for combined mode and fallow audit. Precedence (highest to lowest): force-on CLI flags (--production, --production-{dead-code,health,dupes}), the global force-off flag --no-production, per-analysis env var, global FALLOW_PRODUCTION, config (production: true legacy, production: { health: true, ... } per-analysis). --no-production (global, conflicts_with = "production", issue #1055) forces production OFF for every analysis, overriding a project config's production: true; it exists so the VS Code fallow.production: "off" state forces off on the CLI-driven sidebar the same way the LSP initializationOptions.production override does. resolve_production_modes::resolve_one reads it between the force-on flags and the env vars; unsupported_security_global rejects it like --production.
  • FALLOW_REVIEW_GUIDANCE: default-off toggle for collapsed per-finding guidance in review-github / review-gitlab inline comments. Truthy values are 1, true, yes, and on.
  • FALLOW_TELEMETRY: opt-in telemetry mode. off/0/false/disabled, on/1/true/enabled, or inspect/debug/log (print the exact payload to stderr without sending). Wins over the user config file; the admin kill switches win over it.
  • FALLOW_TELEMETRY_DISABLED: admin/fleet kill switch. Truthy (1/true/yes/on) hard-disables telemetry and refuses fallow telemetry enable. Top precedence alongside DO_NOT_TRACK.
  • FALLOW_TELEMETRY_DEBUG: truthy is an alias for FALLOW_TELEMETRY=inspect.
  • FALLOW_AGENT_SOURCE: normalized agent vendor for telemetry classification. Allowlist: codex, claude_code, cursor, copilot, opencode, aider, roo, windsurf, gemini (aliases gemini_cli/antigravity), cline, continue, zed, goose, other_known, unknown, none. Hyphen aliases normalized; unrecognized values ignored.
  • FALLOW_INTEGRATION_SURFACE: telemetry integration_surface override read by integration_surface() BEFORE the env/format derivation. Allowlist: mcp, lsp, vscode, napi, programmatic (the non-CLI surfaces only; CLI surfaces stay auto-derived so an override cannot relabel a genuine CLI run). Set by the MCP server on the CLI it spawns so MCP tool calls are tagged mcp instead of cli_json. Unknown/empty falls through to derivation. Only read when telemetry is On/Inspect.
  • FALLOW_MCP_TOOL: telemetry mcp_tool dimension, validated CLI-side against the shared MCP tool manifest (fallow_types::mcp_manifest::MCP_TOOLS); any other value is dropped to None (never echoed into the payload). Set by the MCP server alongside FALLOW_INTEGRATION_SURFACE=mcp. Only read when telemetry is On/Inspect.
  • DO_NOT_TRACK: honored as a top-precedence telemetry kill switch (consoledonottrack.com convention).
  • FALLOW_AUDIT_CACHE_MAX_AGE_DAYS: GC threshold (in whole days) for persistent reusable base-snapshot worktree caches under fallow audit. Wins over audit.cacheMaxAgeDays config field. Default 30 days. 0 disables the sweep entirely; invalid values (non-integer) silently fall back to config / default so a typo in a runner env var does not fail audits. Resolved by audit::resolve_cache_max_age; see issue #498.
  • FALLOW_AUDIT_BASE: pins the fallow audit comparison base when no --base / --changed-since is passed (issue #1168). Precedence in audit::resolve_base_ref: explicit --base flag > FALLOW_AUDIT_BASE env > auto_detect_base_ref. The escape hatch exists because the generated agent gate (fallow hooks install --target agent, setup_hooks/fallow-gate.sh) runs bare fallow audit with no base, so a fork / custom-remote consumer can pin (e.g. FALLOW_AUDIT_BASE=upstream/main) without editing the regenerated-on-reinstall script. Parsed by parse_audit_base_override (trim + reject empty/whitespace-only); a non-empty value is validate_git_ref'd and a malformed value is a LOUD exit 2 (unlike the lenient cache-age env), mirroring --base. auto_detect_base_ref itself resolves to a git merge-base SHA against @{upstream} then the remote default (origin/HEAD -> origin/main -> origin/master), falling back to the upstream/remote tip on merge-base failure (shallow clone) and to a local main/master branch when there is no origin (air-gapped repos). The bug it fixes: the old auto_detect_base_branch discovered the default via origin/HEAD but returned the BARE name main, which git resolves to the stale LOCAL refs/heads/main on worktree checkouts. AuditResult.base_description (NOT serialized; AuditResult has no serde derive, JSON is hand-built in audit_output.rs) carries the provenance for the human scope line, short-SHA'd via audit_output::short_base_ref.
  • FALLOW_MAX_FILE_SIZE: per-file size ceiling in megabytes for source discovery (issue #1086). Default 5 MB; 0 = no limit. Source files strictly larger are skipped at discovery (never read/parsed/analyzed); .d.ts files are exempt. The --max-file-size global flag wins over the env var. Held in runtime_support::MAX_FILE_SIZE_OVERRIDE (a OnceLock set once from main() after parse, not threaded through the 10 load_config_for_analysis callers), read by resolve_max_file_size_mb, applied POST-resolve onto ResolvedConfig.max_file_size_bytes via fallow_config::resolve_max_file_size_bytes. Skipped files surface in workspace_diagnostics[] (kind: skipped-large-file) and an aggregated stderr warn. There is intentionally NO .fallowrc.json config field yet (deferred: a FallowConfig field would break ~28 full-literal construction sites) and NO per-tool MCP param (the default-on skip protects MCP runs; the env var reaches the spawned CLI for overrides).
  • FALLOW_SUGGESTIONS: set to off/0/false/no/disabled to suppress the command-level next_steps[] array in JSON output and the human Next: line. Default on. Parsed by report::suggestions::suggestions_enabled (pure helper suggestions_enabled_from is unit-tested without env mutation). Registered in the fallow schema env-var manifest (schema.rs::ENVIRONMENT_VARIABLES). The escape hatch for CI consumers that snapshot-diff raw --format json; it is inherited by the MCP-spawned CLI (run_fallow does not strip env), so it disables next_steps on MCP responses too.

JSON error format

Structured JSON errors on stdout when --format json is active: {"error": true, "message": "...", "exit_code": 2}