| paths |
|
|---|
Key modules:
main.rs, CLI definition (clap) + command dispatcherror.rs, Structured error output (emit_error): JSON on stdout when--format json, stderr otherwiseaudit.rs: Audit command (combined dead-code + complexity + duplication for changed files), verdict (pass/warn/fail). The public entry pointrun_auditabsolutizes path-shaped INPUT FILE flags (--coverage) againstopts.rootbefore constructing theAuditOptionsthat flows intoexecute_audit.compute_base_snapshotswapsopts.rootto a temp git worktree directory, so any new path-shaped INPUT FILE flag added toAuditOptionsMUST resolve against the user's project root atrun_auditrather than at the load site, otherwise downstreamresolve_relative_to_rootcalls 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 newAuditOptions { <field>: resolved.as_deref(), ..*opts }. Path-shaped flags whose VALUE is a prefix of paths INSIDE the input data (--coverage-rootstrips a prefix from Istanbul-data paths) are validated up-front viahealth::scoring::validate_coverage_root_absoluteand 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::createandreuse_or_createuseWorktreeCleanupGuard<'a>(hand-rolled RAII,defuse(&mut self)after struct construction, idempotent) to roll back BOTH the on-disk dir ANDgit worktreeregistration on early-return paths between subprocess success and struct binding. Registration is now TRANSIENT (issue #1815): immediately aftercreate_detached_base_worktreesucceeds,unregister_worktreederegisters the worktree while KEEPING its directory, so the base-snapshot cache never appears in the host repo's sharedgit worktree list(IDE/GitLens/JetBrains clutter).unregister_worktreetargets the single admin dir named by the<path>/.gitgitfile pointer (gitdir: <host>/.git/worktrees/<name>) rather than a globalgit 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>/.gitgitfile is REPLACED with an invalid stub (gitdir: fallow-audit-unregistered), never deleted: both discovery walkers useignorewithrequire_giton, whose gitignore handling is gated on<root>/.gitexisting, 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_readychecks the cache dir exists AND a.shasidecar (written strictly AFTER a successful materialization + deregistration, under the reuse lock) records exactlybase_sha, replacing the old in-worktreegit rev-parse HEADprobe (which read the host admin dir's HEAD, not snapshot content, so fidelity is equivalent). Pre-#1815 registered caches are migrated warm bytry_migrate_legacy_reusable_cache(one last in-worktreegit rev-parse HEADseeds.sha, then deregisters in place). Non-persistentDropis nowremove_dir_allonly (nogitsubprocess), so a SIGKILL never leaves an admin entry.reuse_or_createadditionally acquiresReusableWorktreeLock(kernelflock(2)/LockFileExviastd::fs::File::try_lock, stable since 1.89) on<reusable_audit_worktree_path>.lockto serialise concurrent runs against the samebase_sha; on contention the caller falls through to the non-reusable PID-named path (also deregistered right after add).process_is_alivenow has a real Windows implementation undermod windows_process(target-gatedwindows-sysdep,OpenProcess + WaitForSingleObjectwithProcessHandle(HANDLE)RAII forCloseHandle);ERROR_ACCESS_DENIEDis treated as alive (conservative, mirrors Unixkill -0EPERM).remove_audit_worktreeemitstracing::warn!only whengit worktree remove --forcereturns non-zero AND the dir survives, observable viaRUST_LOG=warn. Any new code that mutates worktree filesystem state betweengit worktree addsuccess 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_auditcallssweep_old_reusable_caches(repo_root, resolve_cache_max_age(opts), opts.quiet)(anOption<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_pathsoverfallow-audit-base-cache-{repo_hash:016x}-, the same hashreusable_audit_worktree_pathcomputes), NOTgit worktree list(which no longer sees the deregistered caches) and NOT a git-registered count: the old "NOT rawread_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'scacheMaxAgeDays = 0opt-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_cachesoverlist_audit_worktrees): it seeds.shafrom HEAD and deregisters pre-#1815 registered entries (auto-cleaning the reporter'sgit worktree listbacklog on the first post-upgrade audit), and thegit worktree prune --expire=nowis 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-usedmtime is older thanmax_age(directory +.last-used+.sha, nogitsubprocess). Sidecar-orphan reclaim: an entry whose cache DIRECTORY no longer exists (!path.exists(), an external$TMPDIRreaper / container restart / CI cache eviction deleted the dir but left its sidecars) has its.last-used/.shareclaimed eagerly BEFORE the age branch, lock-guarded and re-checked under the lock against a concurrent rebuild; this runs even whenmax_ageisNone(age GC disabled viacacheMaxAgeDays = 0), so dead sidecars do not accumulate forever. This also fixes a pre-#1815 leak: a manualgit worktree removeleft.last-used/.locksidecars the old git-scoped sweep could never see.sweep_orphan_audit_worktreesmirrors the same shape for the non-reusable PID-named path: a legacylist_audit_worktreesderegistration pass for dead-PID registered leftovers, then a global temp-dir prefix scan thatremove_dir_alls unregistered dead-PID worktree directories. The age branch's sidecar logic only runs oncepath.exists()is confirmed, so the missing-dir case never re-touches a sidecar for a reaped entry.reuse_or_createtouches 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-candidateReusableWorktreeLock::try_acquiregates the removal; on contention the sweep skips the entry (an in-flightfallow auditmid-rebuild is preserved). The.locksidecar is intentionally NEVER deleted by the sweep: an unlinked-but-still-flocked inode plus a racer'sopen(O_CREAT)at the same path would produce two processes holding kernel flocks on different inodes. The sweep emits atracing::info!summary line on non-empty reclaim AND a stderr"fallow: reclaimed N stale base-snapshot caches"when!opts.quiet; per-entry removal failures emittracing::warn!. Threshold resolution lives inresolve_cache_max_age:FALLOW_AUDIT_CACHE_MAX_AGE_DAYSenv wins, thenaudit.cacheMaxAgeDaysconfig field, then 30-day default;0from either source returnsNone(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.shasidecar differs.ReusableWorktreeLockstays held for the completeBaseWorktreelifetime so cleanup or a different-base rebuild cannot remove files under an active audit. On Unix, freshly materialized worktree roots are mode0700, 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 usescreate_newso predictable names never follow attacker-planted symlinks. Deregistration may remove only an admin directory under this repository's resolved Git common directory, with a matchinggitdirbacklink to the cache. Never trust the cache-controlled.gitpointer 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.shasidecar differs.ReusableWorktreeLockstays held for the completeBaseWorktreelifetime so cleanup or a different-base rebuild cannot remove files under an active audit. On Unix, freshly materialized worktree roots are mode0700, 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 usescreate_newso predictable names never follow attacker-planted symlinks. Deregistration may remove only an admin directory under this repository's resolved Git common directory, with a matchinggitdirbacklink to the cache. Never trust the cache-controlled.gitpointer 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
cacheMaxAgeDayspolicy 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
cacheMaxAgeDayspolicy for sibling roots. check.rs, Analysis pipeline, tracing, filtering, output.check/output.rs::handle_trace_exportresolves--trace FILE:NAMEas a top-level export first; on an export miss it falls back tofallow_engine::trace::trace_class_member(issue #1744), which finds the class / enum / store export whosememberscontainsNAMEand reports the OWNER export's reachability + usage (reusingtrace_export) plus a--unused-<kind>-members --filepointer, rather than erroringexport not found. The trace path runs on the graph only (noAnalysisResults), so it does NOT report per-member crediting provenance. Trace JSON goes through the genericreport::json::print_trace_json(NOT indocs/output-schema.json), soClassMemberTraceneeds no schema/TS-codegen regen.dupes.rs: Duplication detection, baseline, cross-reference.ignoreImportsdefaults totrue(issues #1224 and #1225):DupesOptions.ignore_imports: Option<bool>is the CLI/caller override (Nonedefers to config) resolved by precedence (CLI > config > default) at the singlebuild_dupes_configchokepoint, 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 viaresolve_ignore_importsinmain.rs) are clapconflicts_withtheir opt-in pair and rejected byunsupported_security_global;print_ignore_imports_noteemits a human-format-only stderr hint when module wiring was excluded and clone groups were reported (gated onDupesResult.ignore_imports+ non-empty report).--trace(viarun_clone_trace) accepts two address forms:FILE:LINE(clones at a location) anddup:<id>(a clone-group content fingerprint), dispatched on thedup:prefix tofallow_engine::trace::trace_clone_by_fingerprintvstrace_clone. The fingerprint is assigned byfallow_engine::duplicates::CloneFingerprintSet(xxh3over the representative instance fragment, usually low-32-bit 8-hex, widened only on rare report collisions), surfaced in the human listing and onclone_groups[].fingerprintin JSON (via theCloneGroupFinding/AttributedCloneGroupFindingwrappers inoutput_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-effortsuggested_name(deepdive::dominant_identifier,Noneon generic/tie).health/: complexity analysis.print_health_resultowns the standalonefallow healthexit-code gate (issue #786):--report-onlyshort-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-scoreis authoritative for the complexity-findings gate, so when it is set complexity findings are demoted to informational and the exit code is driven byscore >= N(making--min-score 0always exit 0);--min-severityre-arms a severity-filtered findings gate and composes with--min-score; with no gate flag, any finding fails (back-compat).--report-onlyis rejected (exit 2) alongside--min-score/--min-severityindispatch_health. Combined and audit callers passreport_only: falseandmin_score: None(they own their own gate semantics).mod.rs(orchestration +SubsetFilterfor workspace/group scoping;health_action_optscomputesreport::HealthActionOptionsfromHealthResult::baseline_activeandhealth.suggestInlineSuppressionconfig to drive JSONsuppress-lineaction emission and the top-levelactions_metabreadcrumb),scoring.rs(file scoring +AnalysisCountsSnapshotfor per-subset analysis-count recomputation),hotspots.rs,targets.rs,ownership.rs(bus factor, drift, declared owner cross-ref for--ownership),grouping.rs(per-groupvital_signs/health_scorerecomputation for--group-by package|owner|directory|section, reusesSubsetFilter::Paths). CRAP-finding action selection inreport::json::build_health_finding_actionsis formula-aware: emitadd-tests(tier=none) orincrease-coverage(tier=partial/high) as primary ONLY when full coverage can clear CRAP (cyclomatic < max_crap_threshold); whencyclomatic >= max_crap_threshold(CRAP bottoms out at CC at 100% cov), drop the coverage action and emitrefactor-functioninstead. When the finding carries aninherited_fromfield (synthetic Angular<template>findings whose CRAP was redirected to the owning.component.tsvia the inversetemplateUrledge; seecrates/cli/src/health/scoring.rs::build_template_inherit_contextsfor the walker),build_crap_coverage_actionoverrides the coverage action: it emitsincrease-coveragewith atarget_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.htmltemplate. Thetarget_pathfield is schemarized on the typedHealthFindingActionstruct incrates/types/src/output_health.rsso JSON consumers and the TS codegen pick it up. CRAP findings carrycoverage_source; project summary and grouped health JSON exposecoverage_source_consistency(uniform/mixed) whenever emitted CRAP findings have source data.health.crapRefactorBand(default 5) triggers a secondaryrefactor-functionon CRAP-only findings whose cyclomatic is within the configured band, gated oncognitive >= max_cognitive_threshold / 2to suppress false positives on flat type-tag dispatchers (high CC, near-zero cog).--churn-file(issue #980, global flag): imports change history from afallow-churn/v1JSON file (fallow_engine::churn::analyze_churn_from_file) instead ofgit log, so hotspots/ownership/targets work on non-git VCS (Yandex Arc, Mercurial, Perforce).hotspots.rs::fetch_churn_databranches onopts.churn_fileBEFORE theis_git_repocheck and returns aChurnFetchResultwhosesincedisplay isimported_since()="imported churn"(the file is authoritative for the window;--sinceonly labels output, both the■ Metrics:line and the### Hotspotsheader). The import reusesbuild_churn_result, so imported and git churn aggregate identically. A malformed file is a LOUD hard error (exit 2, single--format jsondocument):mod.rs::validate_churn_fileruns up front in BOTHexecute_healthandexecute_health_with_shared_parse, gated onhotspots || targets(mirrorsfetch_churn_data'sneeds_churn;--ownershipis subsumed because dispatch setshotspots = hotspots || ownership). The file is re-read infetch_churn_dataafter the gate (cheap, bounded byMAX_CHURN_EVENTS); the gate owns the error, sofetch_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-pointcontributions[]array to each complexity finding in--format json(the per-increment breakdown recorded byComplexityVisitor, see.claude/rules/detection.md). Threaded throughHealthOptions.complexity_breakdownintocollect_findings+merge_crap_findings, which cloneFunctionComplexity.contributionsonto eachComplexityViolationonly when set (default off keeps CLI/CI/SARIF/markdown output lean; gated at population, omitted viaskip_serializing_if). Drives the VS Code inline editor breakdown; exposed on the MCPcheck_healthtool ascomplexity_breakdown.signal/(issue #477): process-wide signal handling + scoped child-process registry.install_handlers()at the top ofmain()registers SIGINT/SIGTERM on Unix (signal_hook::iterator::Signalson a dedicatedstd::thread; the worker does a blockingsigwaitso the body is regular Rust with no async-signal-safety constraints) orSetConsoleCtrlHandleron Windows (CTRL_C_EVENT/CTRL_BREAK_EVENT-> 130,CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT-> 143).ScopedChildis the RAII wrapper any long-running spawn site must use; it stores the Child handle locally and the registry stores only the PID (monotonicAtomicU64key, NOT the recyclableChild::id()). The signal handler kills by PID side-channel (kill -9 <pid>shell exec on Unix,TerminateProcesson Windows) so the wrapper retains ownership forwait_with_output. Bounded drain budget: 500ms Unix, 1500ms Windows (the OS force-kills Windows console-control handlers after ~5s). One-shot guard viastd::sync::Onceso signal storms do not re-enter the lock. Adoption threshold: wrap any spawn whose expected wall time exceeds ~1s. Today:fallow-covsidecar,npm install -g, self-invokedfallow health,git log --numstatchurn analysis (via thefallow_engine::churn::set_spawn_hookfunction-pointer install at startup so core stays cli-independent), andgit worktree add/reuse/removeaudit operations.fallow watchopts into cooperative mode viaGracefulModeGuard: the handler then only sets a shutdown flag, the watch loop'srecv_timeout(200ms)polls it and returnsExitCode::from(0)because Ctrl+C is watch's documented termination path. Integration tests use a sub-process pattern viaenv!("CARGO_BIN_EXE_fallow")plus aFALLOW_TEST_SIGNAL_HELPER=1env-gated helper subcommand, NOT self-signal-from-test-thread (which would take down sibling parallel tests).watch.rs, File watcher with debounced re-analysisfix/: Auto-fix.exports.rs,enum_members.rs,deps.rs,catalog.rs(line-aware YAML deletion of unusedpnpm-workspace.yamlcatalog entries; skips entries with non-emptyhardcoded_consumers, rejects multi-doc YAML, reparses withserde_yaml_ngbefore persisting; per-instanceauto_fixableflips computed inreport/json.rs::build_actions),io.rs(low-levelread_sourcereturningResult<Option<(String, EncodingMetadata)>, EncodingError>withEncodingMetadata { line_ending, had_bom }andEncodingError::MixedLineEndings; pureclassify_sourcehelper reused by the staged-content fast path;bytes_with_optional_bomfor whole-file rewrites incatalog.rs; re-export offallow_config::atomic_write),plan.rs(issue #454 batch-atomicity layer:FixPlanaccumulator,read_source_with_hash_checkhelper,SkipReason::ContentChanged | MixedLineEndings | LowConfidenceOffGraph | LowConfidenceUnresolvedImports, stage-then-rename commit viaNamedTempFile).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 viaread_source_with_hash_checkto get(String, EncodingMetadata), splits onmeta.line_ending, and passes&metatostage_fixed_contentso the UTF-8 BOM (EF BB BF) re-prepends on write when the source had one (issue #475). The orchestrator runsanalyze_with_file_hashes(newfallow_coreentry that returnsAnalysisOutputwithfile_hashes: FxHashMap<PathBuf, u64>populated fromModuleInfo.content_hashxxh3, 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 aSkipping <path>: file content changed since fallow check ranstderr line (gated on!opts.quiet), and contributes to a non-zero exit. Mixed CRLF/LF source is rejected up front (issue #475) withSkipReason::MixedLineEndings, surfaces a stderr line namingdos2unixandgit config core.autocrlf inputas 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__targetsidechannel field on per-fixer JSON entries correlates fixes to absolute paths for post-commitapplied: falsepatching; it is stripped bystrip_target_sidechannelbefore 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 fromskipped),"skipped_mixed_line_endings"(additive sibling, mixed-EOL skip count, also disjoint fromskipped), and"skipped_low_confidence_exports"(issue #602, always present, disjoint fromskipped; combined count of export removals withheld because the file sits under an off-graph consumer directory inexports::OFF_GRAPH_CONSUMER_DIRS(__mocks__,__fixtures__,fixtures,e2e,e2e-tests,cypress,playwright,examples,evals,golden) OR appears inresults.unresolved_imports; per-recordskip_reasonislow_confidence_off_graph/low_confidence_unresolved_imports). The twolow_confidence_*skips are INTENTIONAL: they do NOT contribute to the non-zero exit code (unlike content-changed / mixed-EOL); the export stays reported byfallow checkfor manual review. The gate lives inexports::apply_export_fixesand runs before the source read; only the export fixer gates (enum / dep / catalog fixers are unaffected). Human stderr emits aRun pnpm install to refresh pnpm-lock.yamlreminder after any successful catalog edit, aSkipped N file(s) that changed since fallow check ranreminder after any hash-mismatch skip, and aSkipped N file(s) with mixed CRLF/LF line endingsreminder after any mixed-EOL skip.package.jsonandpnpm-workspace.yamlare NOT infile_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_rulefor duplicate-exports config-add) keeps its own internalatomic_writepath and is intentionally NOT batched (single-file, low-risk).codeowners.rs, CODEOWNERS file parser, ownership lookup for--group-by ownerreport/, 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 jsonpassesJsonStyletoemit_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-levelnext_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 humanNext:hint on barefallow(TTY-only, mirrors the explain-tip gate viatop_combined_next_step). Two contracts enforced by thenext_stepconstructor'sdebug_asserts AND unit tests: everycommandis runnable as-is (no<...>placeholder) and never mutating (nofix/init/hooks/migrate/setup-hooks). Triggers (capped atMAX_NEXT_STEPS = 3, deduped byid, priority-orderedsetup>impact-report>trace-unused-export>scope-workspaces>trace-clone>complexity-breakdown>audit-changed):setup(first-contact pointer; command is the read-onlyfallow schemaBECAUSEinit/hooksare banned MUTATING_VERBS tokens; gated by the caller-computedsetup_pointer_applicable(root)= no config file up to repo root +!telemetry::is_ci()+!onboarding_declined),impact-report(at-most-weekly local value digest; caller-computeddue_impact_digest(root)peek-and-stampslast_digest_epochin 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 whendiscover_workspacesnon-empty ANDresolve_default_workspace_refresolves a concrete ref via a self-containedgit symbolic-ref/rev-parse --verifyprobe, else omitted to stay placeholder-free),audit-changed(gated onis_git_repo). The builders takeoffer_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_outputreturns emptynext_steps(so the combined/auditchecksub-block built viabuild_check_json_payload_with_config_fixablestays empty), andbuild_json_with_config_fixable/build_health_json/build_duplication_json/print_grouped_json/print_combined_json/print_audit_jsoninject the array at the top level.auditemits onlytrace-unused-export+complexity-breakdown(noaudit-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 dueimpact-reportdigest may ride a clean run (a clean project after containment is exactly when the value report informs). Human counterparts live incombined/output.rs::print_failure_summaryafter theFailed:stderr line: a dimmedImpact:digest line and theSETUP_HINTline (deliberately NOT TTY-gated, agents reading piped human output are a primary audience; quiet gated by the caller).top_combined_next_steppassesoffer_setup: false+digest: Noneso the humanNext:slot always shows an analysis follow-up. Additive-optional on the wire (skip_serializing_if = "Vec::is_empty"), never contributes tototal_issues, noschema_versionbump.report/sink.rs: process-globalMutex<Option<BufWriter<File>>>sink (None = stdout) read by theoutln!/out!macros that every report-CONTENT print site uses instead ofprintln!/print!.--output-file/-o(global flag, valid with dead-code/dupes/health/security/bare, mirrors--sarif-file's command gate) makesmain.rs::redirect_report_to_fileopen 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_fileflushes after dispatch and printsReport written to <path>on stderr (suppressed by--quiet). The sink is ambient so no*Optionsstruct threads the path; programmatic/NAPI consumers use thebuild_*helpers and never set the sink, so they are unaffected. Interactive TTY chrome (the--explaintip inreport/human/mod.rs, the combined orientation explain-tip incombined.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/stylelintinit.rs, Generate config files (.fallowrc.jsonorfallow.toml), scaffold starter agent guides (--agents), scaffold pre-commit git hooks (--hooks), record a deliberate stay-unconfigured decision (--decline, conflicts with the scaffolding flags; persistsonboarding_declinedin the impact store viaimpact::decline_onboarding, writes NO config file, suppresses the first-contactsetupnext-step + humanSetup:hint)list.rs, Show active plugins, entry points, files, boundary zones/rules (--boundaries)viz.rs,vizcommand: one engine-owned project analysis (AnalysisSession::analyze_project_with_artifacts, complexity + graph retained) handed tofallow_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/mermaidtext. The HTML embeds the built TS frontend fromviz-frontend/(rolldown bundle atcrates/cli/viz-assets/viz.js+viz.css,include_str!'d; rebuild viacd viz-frontend && npm ci && npm run build). Read-only, telemetryWorkflow::ProjectInventory.plugin_check.rs,fallow plugin-checkread-only dry-run for external-plugin (manifestEntries) authoring. Loads config viafallow_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 sharedRuleReport(fallow_engine::plugins::check_manifest_entries) as bespoke JSON with per-seedpath_exists+ typedwarnings[]. Exit 0 always. See.claude/rules/plugins.mdfor the full loop.schema.rs,schema,config-schema,plugin-schema,rule-pack-schemacommands (the schema printers live ininit.rs;rule-pack-schemaprintsRulePackDef::json_schema()).fallow schemais the agent capability manifest:manifest_version"1", clap-derived commands/global_flags,issue_typesderived 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 throughIssueKind::parse, and filter-flag existence on live clap), anmcp_toolsblock fromfallow_types::mcp_manifest, a live-derivedpluginsblock, atask_matrixblock (the agent task-to-command cheat sheet fromcrate::task_matrix::TASK_MATRIX, the same rows asinit --agents, thehooks install --target agentmanaged 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-infallow securitycommand surfacing local security CANDIDATES (not verified vulnerabilities). MVP ruleclient-server-leak(detector incrates/core/src/analyze/security/mod.rs).run()loads config viaload_config_for_analysis, forcesrules.security_client_server_leakfromofftowarn(respecting an explicit usererror), runsfallow_core::analyze, readsresults.security_findings+results.security_unresolved_edge_files+ unresolved-callee diagnostics, applies--workspace/--changed-since/--fileretains, relativizes finding + trace paths, and rendersSecurityOutput(human / JSON / SARIF).--filestill 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 atlevel: notewithpartialFingerprints(FNV) and no CWE; trace hops becomerelatedLocations. Exits 0 unless the user set the rule toerrorAND findings exist.SecurityOutput/SecuritySchemaVersionare aFallowOutput::Securityvariant registered inschema_emit.rs;--format jsonis in the published schema + TS contracts. Findings and unresolved-callee diagnostics are#[serde(skip)]onAnalysisResults, so they never appear under barefalloworaudit. Agent-actionable candidate record (issue #900): everySecurityFindingcarriescandidate { source_kind, sink, boundary }plus optionaltaint_flow { source, sink, path }and a top-levelfinding_id. Slot 1source_kindis the stable catalogue source id, threaded through fromtainted_sink::sink_source(it returns(id, title)now; the id was previously discarded after settingsource_backed). Thesinkslot is self-contained (path/line/col/category/cwe/callee) and set by the detectors; theboundaryslot (client_serverfrom aClientBoundarytrace hop,cross_modulefrom the reachability hop count,architecture_zonefrom the run's boundary-violation zone names) andtaint_floware filled byrank::enrich_candidate.rank_security_findingsnow takes aboundary_crossings: FxHashMap<PathBuf, (from_zone, to_zone)>map (built inanalyze/mod.rs) instead of the old path set, so it can populate bothcrosses_boundaryAND the zone slot.taint_flow.pathis the compact{ intra_module, cross_module_hops }shape; the full ordered hops stay onreachability.untrusted_source_trace, never duplicated. There is deliberately NOimpactwire field (agent-owned, documented in the schema doc comment).finding_idis the SARIF FNV fingerprint extracted to the sharedsecurity_finding_idhelper, stamped inrun()after relativization so it equals the SARIFpartialFingerprintsvalue;relativize_findingalso relativizes the candidate/taint_flow paths.export_visibilityand 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-networkexfil category (issue #890): an INCLUDE-REQUIRED catalogue category (CWE-201) admitted only viasecurity.categories.include(gated bytainted_sink::is_include_required_categorycallingCategoryFilter::explicitly_admits, likehardcoded-secret). A non-publicprocess.env/import.meta.envsecret reaching a network sink's body/options arg via same-identifier source-backing. Three load-bearing pieces: (1) the newrequires_source_kinds: Vec<String>matcher field narrowsrequires_sourceto specific source ids;matcher_admits_sinknow takes the matched source as(id, title)(the call site stopped discarding the id) and gates onrequires_source_kindscontaining the id. (2) The sharedis_public_env_var/is_public_env_pathpredicate moved tofallow_types::extract; the extract layer skips public env vars intainted_source_pathANDcollect_source_paths_into(the latter must return BEFORE recursing into the bareprocess.envobject, or the object prefix re-introduces the source, the bug that broke the #876 public-env regression).flatten_member_pathgained aMetaPropertyarm soimport.meta.env.Xis modeled as a source. (3)SinkSite.url_arg_literalcaptures the network call's arg-0 URL literal (call_url_arg_literal); forsecret-to-networkfindings,tainted_sinkbuildscandidate.network: SecurityNetworkContext { destination }(the literal host, orNonefor a dynamic destination, the suspicious case).CACHE_VERSION135 -> 136. Hardcoded-secret-as-source and a provider-match heuristic are reserved follow-ups.--gate <mode>(issue #886, valued flag,newandnewly-reachable; noallmode 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 (viachanged_files::try_get_changed_diff, which returnsErron git failure rather thanget_changed_files' silentNone), NEVER a green gate. The gate filter is the STRICTcheck::filtering::retain_gate_new(a SEPARATE pass from the advisoryfilter_results_by_diff, leaving advisory display untouched): keep a new sink anchor on an added line OR aUntrustedSource/Sinktrace hop on an added line; DROP theSecretSource && touches_filefile-level exception andIntermediate/ClientBoundarypass-through hops (so editing a secret-reading file does not trip the gate).--changed-sincefor the gate builds a LINE-level diff (git diff --relative --unified=0 ref...HEAD); the existing--changed-sincefile-level filter is unrelated. Exit contract: 8 = new candidate (PURE: never "could not run"), and the gate SUPERSEDES the advisory--fail-on-issuesexit-1 path in gate mode (composing would re-gate the backlog). Thegateblock onSecurityOutput(mode/verdict/new_count, snake_case, emitted on pass too so consumers distinguish "gate ran and passed" from "gate did not run") is additive onSecuritySchemaVersionV2; human prose says "REVIEW REQUIRED" (not FAIL) with the unverified disclaimer; SARIF keepslevel: noteand rides the gate as arun.properties.fallowGateproperty. Canonical pre-commit recipe:git diff --cached --unified=0 | fallow security --gate new --diff-stdin(staged content;--changed-sincechecks committed HEAD).--gate newly-reachablerequires--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 withreachability.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-fileor--diff-stdinwithout--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-cacheis set.- Security subcommands are part of the CLI contract:
security survivorsjoins rawfallow security --format jsonoutput with verifier verdict JSON, emitssummary.unverdicted, and supports--require-verdict-for-each-candidatefor complete-verdict CI gates;security blind-spotsgroups unresolved-callee diagnostics and accepts--fileboth before and after the subcommand. ci_template.rs,ci-template gitlabsubcommand: prints the bundled GitLab CI template, or with--vendor [DIR]writes the template plus bash helper files (3ci/scripts/:comment.sh,review.sh, and thegitlab_common.shthey bothsource, plus 1ci/gitlab-ci.yml= 4 files total) into a project so vendored pipelines avoidraw.githubusercontent.comat runtime.--forceis required to overwrite files that differ. The bundled template paths undercrates/cli/templates/ci/are git symlinks to the canonical workspaceci/sources, so contributors edit one source of truth;cargo packagedereferences the symlinks into regular files so the published crate stays self-contained (Windows checkouts must rungit config --global core.symlinks trueperCONTRIBUTING.md). The bundled file list (GITLAB_FILES) is kept in sync with thefor f in ...; do cp ...loops in the template by a unit test (gitlab_ci_template_for_loops_match_vendored_files).ci.rs,cisubcommands for provider-aware review automation.reconcile-reviewreads a typedreview-github/review-gitlabenvelope, 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 throughextract_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 capturev2:as the fingerprint. The bundledaction/scripts/review.sh+ci/scripts/review.shmirror the same two-shape sed dedup pattern and accept anyfallow-review-envelope/v<N>schema viatest("^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 tofallow-review-envelope/v2(issue #528). ConstantsMARKER_PREFIX_V2,MARKER_SUFFIX_V2,MARKER_REGEX_V2, andMARKER_REGEX_FLAGS_V2(all inoutput_envelope) must change together. The regex carries no inline flag group ((?m)is JS-incompatible); themflag is emitted alongside inmarker_regex_flagsso consumers pass both to their engine. Render pipeline:group_by_path_linecollapses consecutive same-(path, line) issues, thenrender_merged_commentemits one comment per group. Multi-finding groups get amerged:<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_fingerprintsis NOT emitted on the wire; consumers that want update-in-place reconciliation track identity themselves viamarker_regex.FALLOW_REVIEW_GUIDANCE=trueappends per-finding collapsed "What to do" blocks fromexplain.rsrule 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 perNote#notevalidation) 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. GitLabposition.old_pathis populated fromDiffIndex.rename_pairs(parsed fromrename from/rename toextended-diff headers when--diff-fileor--diff-stdinis supplied); falls back to head-side path otherwise.render_review_envelope_with_diffis the test-shim signature that accepts a hand-craftedDiffIndexwithout touching the process-wideSHARED_DIFFcache.config.rs,configsubcommand: prints loaded config path + JSON resolved config (or--pathonly). Honors global--config <path>.api.rs: shared HTTP layer for fallow-cloud backend calls. Exposesapi_agent()/api_agent_with_timeout()compatibility wrappers, fallibletry_api_agent()/try_api_agent_with_timeout()constructors that honorFALLOW_CA_BUNDLE,api_url()(respectsFALLOW_API_URL), typed error-envelope parsing,actionable_error_hint(),http_status_message(),ResponseBodyReader, retry helpers for 429Retry-Afterand 502/503/504, and theNETWORK_EXIT_CODE = 7constant. Used bylicense/(5s/10s timeouts),coverage/upload_inventory(5s/30s timeouts),coverage/upload_source_maps(5s/60s timeouts), explicit cloud runtime pulls incoverage/analyze, and provider CI API calls.FALLOW_CA_BUNDLEis 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|deactivatesubcommands.activateaccepts 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 mode0600. The trial response'strialEndsAtis surfaced on stdout after activation.statusprints a refresh hint when the JWT'srefresh_afterclaim has passed.refreshand--trialhitapi.fallow.cloudvia the sharedapi.rslayer; failures exit7. Wrapsfallow-license(offline Ed25519 verify, alg pinned, RS256/none rejected, 7/30/hard-fail grace ladder, optionalrefresh_afterclaim).telemetry.rs: opt-in product telemetry (telemetry status|enable|disable|inspect [--example]). OFF by default; precedenceDO_NOT_TRACK/FALLOW_TELEMETRY_DISABLED>FALLOW_TELEMETRYenv > user config (<config-dir>/fallow/telemetry.json) > default-off, with CI forced off unlessFALLOW_TELEMETRYis explicit. Payload is allowlisted and coarse (workflow, integration_surface, invocation_context, agent_source, output_format, quiet/ci/tty, os/arch, duration/exit-code buckets, optionalfailure_reason, optionalfindings_present, optionalmcp_tool, sanitizedparent_run); never paths, names, source, config, raw env, or errors.failure_reasonappears only onworkflow_failedevents and is one ofvalidation,unsupported_format,config,analysis,diff,network,auth,gate,signal, orunknown; known reasons are set only at explicit failure boundaries, otherwise failed workflows useunknownrather than parsing raw error text.agent_sourceis a fixed enum classified from explicitFALLOW_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-widenote_findings_presentaccumulator (AtomicU8+fetch_max, OR semantics across combined-mode sub-analyses; assumes one analysis batch per process), decoupling "found something" from the exit-codeoutcomegate so informational analyses like default-configdupes(which never gates) are still measurable; absent on commands that run no analysis.Command::{Impact,Security,Fix,Explain}route to their ownWorkflowvariants (notUnknown).integration_surface()honors theFALLOW_INTEGRATION_SURFACEoverride first (the MCP server sets it tomcpplusFALLOW_MCP_TOOLon the CLI it spawns, so MCP tool calls emit a single event taggedmcp+ per-tool instead ofcli_json); themcp_toolvalue is allowlist-validated against the shared tool manifest (fallow_types::mcp_manifest::MCP_TOOLS). TheWorkflow/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, onceelapsed/exit_codeare known), so at exitrecord_workflow/set_enabled(On mode) append the serialized event totelemetry-spool.jsonl(next totelemetry.json) viaspool_event(lock-free O_APPEND, thentrim_spool_if_oversizeddoes a singlefstatand only rewrites when the file exceedsSPOOL_MAX_BYTES=64KiB);flush_spool_in_background(called inmain.rsright aftersetup_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 viaSpoolLock(a never-deleted.locksidecar, 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 newestSPOOL_MAX_EVENTS=64. Critical design point: the spool is bounded by the WRITE-pathtrim_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-.drainingdesign 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 oldrecv_timeout(UPLOAD_GRACE_MS=200)grace-wait violated by ~50-200ms per run. Dispatched inmain.rsbefore root validation viarun_telemetry_command_if_requested; passive events recorded after post-parse setup and dispatch viarecord_workflow. The global--parent-runflag ishide = trueuntil 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 + aRandomState-seeded u64 + pid + anAtomicU64, full 32-byte hex,inst_prefix; reusessha2, no new dep) minted ONLY inset_enabled(true)(viaensure_install_id) and lazily on the env-on send path (resolve_install_id_for_send-> testable pureresolve_install_id_with(mode, path), mints+persists whenmode == Onand a writable config dir exists, elseNonegraceful fallback; the lazy mint persists ONLY the token, the config-levelenabledflag stays default-off so an env-only opt-in never escalates into a persistent user-config opt-in). It is cleared toNoneonset_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 oldtelemetry.jsonfiles parse asNoneand never serialize a null;CONFIG_SCHEMA_VERSIONstays 1. It rides the SAME out-of-band path as the parent-run token: sent as the privateX-Fallow-Installtransport header (INSTALL_HEADER) for server-sidedistinct_idgrouping, NOT as an event property, soTelemetryEvent/example_event/field_purposes/TELEMETRY_SCHEMA_VERSION=2are untouched.drain_spool_file's poster bound isFnMut(&Value, Option<&str> parent_run, Option<&str> install) -> Result<(), String>; the install id is resolved once at theflush_spool_in_backgroundspawn site and threaded into the drain as a parameter, so unit tests never read the real env/config dir (PR #1198).telemetry statussurfaces onlyinstall_grouping_token(presence boolean, never the token);inspect --exampleliststransport_headers(). The fallow-cloud side mapsX-Fallow-Installto the PostHog distinct_id (validated against the exact mint shape, preferred over the parent-run token,installGroupedproperty 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.jsonand prints one stderr hint only for human output with both stdout and stderr attached to a TTY, not quiet, not CI, and not suppressed byDO_NOT_TRACK,FALLOW_TELEMETRY_DISABLED, orFALLOW_UPDATE_CHECK=off. The background refresh fetches/v1/cli/latest-versionthroughapi_url()with tight timeouts, preserves the cache'sdisabledfield, 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 tofallow health --runtime-coverage).coverage analyze: focused runtime analysis. Local mode reads--runtime-coverage <path>; cloud mode is explicit only via--cloud,--runtime-coverage-cloud, orFALLOW_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_KEYalone must not select cloud mode.coverage upload-inventory: POSTs a static function inventory to/v1/coverage/{repo}/inventoryvia the sharedapi.rslayer. 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 withfallow_extract::inventory::walk_source, emitting Istanbul/oxc-coverage-instrument-compatible names (per-file counter, bodyless functions and.d.tsfiles 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 returnspathOverlapon 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-mapsvia the sharedapi.rslayer so cloud-mode runtime coverage can resolve bundled paths back to original source files. Flags:--dir(defaultdist, scanned recursively),--include(default**/*.map),--exclude(repeatable, default**/node_modules/**),--repo(auto:package.jsonrepository.url→git remote get-url origin, parsed toowner/repoform, matchingupload-inventory),--git-sha(auto:$GITHUB_SHA→$CI_COMMIT_SHA→$COMMIT_SHA→git rev-parse HEAD; empty-string env vars fall through),--endpoint(override base URL; otherwiseFALLOW_API_URLthenhttps://api.fallow.cloud),--strip-path(defaulttrue; emit basename asfileName.--strip-path=falseemits the dir-relative path so monorepo bundlers reporting paths likeassets/app.jsstill resolve),--dry-run,--concurrency(default 4),--fail-fast. API key is read ONLY from$FALLOW_API_KEY(no--api-keyflag, intentional: keeps the secret out of argv). Repo is one URL-path segment, slashes are percent-encoded (owner/repo→owner%2Frepo). Per-map retry: 3 attempts on network, HTTP 429, and HTTP 502/503/504; 429 honorsRetry-Afterdelta 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 outsidelicenseis limited to explicit coverage-cloud commands (coverage analyze --cloud,coverage upload-inventory,coverage upload-source-maps);check/dupes/healthstay offline.
explain.rs, Metric/rule definitions, JSON_metabuilders, SARIFfullDescription/helpUrisource, docs URLsimpact.rs: opt-in, local-only Fallow Impact value report (impactcommand withenable/disable/default on|off/reset [--all]/statussubcommands; bareimpactrenders the report). The store lives in the USER config dir, NEVER in the repo (since v2.96):<config-dir>/fallow/impact/<project-key>.jsonper project plus<config-dir>/fallow/impact.json(the user-globaldefault_enabledtoggle), sharingtelemetry::config_dir()'s base.store_pathreturnsOption<PathBuf>(None = no config dir = inert).impact_project_key(root)derives the file key fromresolve_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 auditis perf-priority). Enabling writes NOTHING into the repo (the oldensure_fallow_gitignoredcall was removed fromenableANDdecline_onboarding). It surfaces three things: surfacing (current issue count from the latest record), trend (count delta vs the previous record), and containment (a gate-markedfallow auditrun that exitedfailthen a later correlated run cleared it).record_audit_runis called fromaudit::run_auditafter the verdict; both record paths early-return ontelemetry::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_enabledprecedence:enabled == trueis an explicit Project opt-in (wins even whenexplicit_decisionis unset, so pre-explicit_decisionstores never regress) > explicit per-repo disable (!enabled && explicit_decision, off as Project) > user-globaldefault_enabled(User) > off (Default).ImpactReportcarriesenabled_source(project/user/default, registered asEnabledSourceinschema_emit.rs); humanstatusprints the resolved store path + project key (machine output omits them so the home path never leaks). On firstloadwith no user store,migrate_legacy_storeimports a pre-relocation in-repo.fallow/impact.jsonvia the deserialize-onlyLegacyFlatStore(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).enabledlives in the store file, so per-project enabling = writing the file withenabled: true. The--gate-markerarg onauditishide = true; the generatedinit --hookspre-commit hook passes--gate-marker pre-commitand the Claude agent gate (setup_hooks/fallow-gate.sh) passes--gate-marker agent(the gate script'sFALLOW_GATE_MIN_VERSIONfloor 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, threesetup_hooks.rstests, docsclaude-hooks.mdx, and skillspatterns.md). The store also carries onboarding/opt-in state:onboarding_declined(set byfallow init --decline, suppresses thesetupnext-step +Setup:hint),explicit_decision(set by BOTHenableanddisable, so declining on a never-enabled project records "asked and said no"; mirrored onImpactReportand in telemetry's config for the telemetry ask), andlast_digest_epoch(peek-and-stamp cadence state for the weeklyimpact-reportdigest viatake_due_digest; internal, never on the report).ImpactReportis aFallowOutputvariant and its types (ImpactReport/ImpactCounts/TrendSummary/ImpactTrendDirection/ContainmentEvent/ResolutionEvent) are registered inschema_emit.rsso--format jsonis in the published schema + TS contracts. v1 reusesvital_signs::chrono_timestampfor record timestamps. v1.5 per-finding attribution (storeschema_version2, forward-compatible read of v1 stores):record_audit_runtakes anOption<&AttributionInput>(built inrun_auditfromresult.check/health/dupesviacollect_dead_code_findings/collect_complexity_findings/collect_clone_findings).apply_attributionmaintains a per-filefrontier(line-independent finding IDs viafingerprint_hash(kind,rel_path,symbol)+ present-suppression kinds) plus a fingerprint-keyedclone_frontier, and diffs them against the files the run re-analyzed (AuditResult.changed_files). Since the v2.96 user-store relocation,frontier/clone_frontierare nestedFxHashMap<worktree_key, FxHashMap<rel_path, ...>>(storeschema_version4) 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_attributionpulls 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.LegacyFlatStorereads 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 byuncredit_cross_run_moves, which drops a prior-run resolution event + decrementsresolved_totalwhen its move-key reappears as a new finding in a later run, bounded byrecent_resolved). The discriminator depends onAnalysisResults.active_suppressions(#[serde(skip)], populated bySuppressionContext::all_suppressionsinanalyze/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>(boundedMAX_RECENT_RESOLVED),attribution_active; human/markdown always render a RESOLVED section (three exhaustive states) with suppression as neutral "marked intentional" context, never a scoreboard.frontieris 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-normalizeto_pathso 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 fromcombined.rs::record_combined_impact) appends to a SEPARATEImpactStore.project_recordsseries (kept apart from the changed-filerecordsso the two scopes never share a trend).AttributionInput.changed_fileswas replaced byScope::ChangedFiles(&[PathBuf]) | Scope::WholeProject; audit passesChangedFiles(result.changed_files), the combined writer passesWholeProject, whose scope (viaresolve_attribution_scope/whole_project_scope) is the union of the frontier keys + this-run finding/clone paths (NOT a module list;AnalysisResultshas nomodulesfield andchanged_filesis a git diff), so a clone or whole-repo cleanup fixed outside a changed-file audit is credited. The combined writer is gated airtight inis_whole_project_run: requiresrun_check && run_dupes && run_health, nochanged_since/workspace/changed_workspaces, no activereport::ci::diff_filter::shared_diff_index()(closes--diff-file/--diff-stdin, which are NOTCombinedOptionsfields), and no production mode; it pullsactive_suppressionsfromcheck_resultso 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_disappearancesskips a disappeared fingerprint whose files still participate in a current clone).ImpactReportgainedproject_surfacing/project_trend(additive optional; reportschema_versionstays 1). Human + markdown render an understated whole-project section with an "advances only on local fullfallowruns, 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.--allis a flag on theimpactcommand (rejected with exit 2 when combined with a subcommand, via a manualdispatch_impactguard not clapconflicts_with, since the subcommand is not a named arg), with--sort {recent,resolved,contained,name}(defaultrecent, bylatest_activitytimestamp desc) and--limit N(caps printed rows only; totals always reflect every store).load_all()enumerates<config-dir>/fallow/impact/*.json(the globalimpact.jsontoggle is a sibling FILE one level up, naturally excluded), skipping corrupt/newer-schema files intounreadable_count(neverImpactStore::default()-substituted).build_aggregate_reportreusesbuild_reportper 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 fromprojects[](counted inproject_count). The wire shape is a NEWFallowOutput::ImpactCrossRepo(CrossRepoImpactReport)variant (kind:"impact-cross-repo", independentCrossRepoImpactSchemaVersion) embedding the per-projectImpactReportverbatim perCrossRepoProjectEntryplusproject_key(the store filename stem; folds in the project_key-on-json follow-up at the aggregate level, NOT onImpactReport, which stays byte-identical) andlabel. The store gained an additivelabel: Option<String>(STORE_SCHEMA_VERSION 4 -> 5) = the git-toplevel BASENAME captured at record time (record_audit_run/record_combined_run/migrate) viarepo_basename, NEVER a full path;project_identitynow 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 oneStores: <config-dir>/fallow/impact/line gated onis_human && !quiet. The 4 cross-repo types register inschema_emit.rsacross 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 viaImpactStoreLock::acquire(root)(kernelflockon a<store_path>.locksidecar, mirroringtelemetry::SpoolLock; best-effortNone=> 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.locksidecar is NEVER deleted (an unlinked-but-locked inode + a racer'sO_CREATwould split the lock). (2) Age-based GC:FALLOW_IMPACT_STORE_MAX_AGE_DAYS(registered inschema.rs::ENVIRONMENT_VARIABLES;resolve_store_max_agereusesbase_worktree::days_to_duration, unset/0/invalid = no sweep) makes a recorded run callsweep_old_stores(keep_key, max_age), which deletes per-project<key>.jsonfiles 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.locksidecars, and the sibling globalimpact.jsontoggle. The MCPimpact_alltool (seemcp-server.md) wrapsfallow 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-baselineand no value. This discovers the active fallow config and updates itsregression.baselinecounts, or creates.fallowrc.jsonwhen no config exists. Passing a path writes a standalone baseline file instead. lib.rs, Library surface for thefallow-clicrate. Re-exportsfallow_engine::codeowners, theexplain,report, anderrormodules, andruntime_support::{AnalysisKind, GroupBy}. The binary (main.rs) owns clap + dispatch. The one-shot programmatic API (detect_dead_code,compute_health, etc.) moved tocrates/api(fallow-api) in the architecture split;crates/napidepends onfallow-apionly (zerofallow-cliedges incargo tree -p fallow-node). Theprogrammatic_common_options_track_analysis_affecting_cli_globalstest inlib.rskeepsfallow_apicommon options in lockstep with analysis-affecting global CLI flags. The formerprogrammatic.rsmodule and itsAnalysisOptions::legacy_envelopeare gone; the CLI--legacy-envelopeflag was removed in v2.104.0 and tagged root envelopes (top-levelkind) are the only wire shape.runtime_support.rs, Sharedbuild_ownership_resolver+load_configused bymain.rsand the command modules, plus theAnalysisKind/GroupByclap enums. Extracted out ofmain.rsso library consumers can reuse them without dragging in the full clap command tree.
Coverage input precedence:
- Standalone
fallow healthand bare combinedfallowresolve 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-codemust reject instead of silently ignoring the bare-mode input before a subcommand. fallow auditkeeps its own CLI/env coverage path and does not consume thehealth.coverageconfig fallback.
FALLOW_FORMAT, default output formatFALLOW_QUIET, suppress progress barsFALLOW_BIN, binary path for MCP serverFALLOW_CACHE_MAX_SIZE, extraction cache (.fallow/cache.bin) cap in megabytes. Default 256. Wins over thecache.maxSizeMbconfig field. Resolved atruntime_support::resolve_cache_max_size_env; threaded into bothCacheStore::load(size ceiling,max(max_size_bytes, DEFAULT_CACHE_MAX_SIZE)so a misconfigured tiny cap does NOT discard a valid existing cache) andCacheStore::save(eviction trigger).--no-cacheshort-circuits.FALLOW_COVERAGE, path to Istanbul coverage data for accurate CRAP scoresFALLOW_COVERAGE_ROOT, absolute coverage-data prefix for CI or container rebasingFALLOW_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'siatclaim during verification. Default 86_400 (24h). A JWT whoseiatis more than this many seconds in the future relative to the local clock is rejected asLicenseError::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 byfallow_license::skew_tolerance_seconds_from_env(); threaded throughverify_jwt_with_skewat all CLI license call sites.FALLOW_COV_BIN, explicit override for the closed-sourcefallow-covsidecar binary (wins over project-localnode_modules/.bin, package-managerbin,~/.fallow/bin/, andPATH). 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 tooff,0,false,disabled, ornoto disable the human-TTY upgrade nudge and its background latest-version check.FALLOW_API_KEY: fallow cloud bearer token. Consumed byfallow coverage upload-inventory(flag--api-keywins) andfallow 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 andfallow 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, globalFALLOW_PRODUCTION, config (production: truelegacy,production: { health: true, ... }per-analysis).--no-production(global,conflicts_with = "production", issue #1055) forces production OFF for every analysis, overriding a project config'sproduction: true; it exists so the VS Codefallow.production: "off"state forces off on the CLI-driven sidebar the same way the LSPinitializationOptions.productionoverride does.resolve_production_modes::resolve_onereads it between the force-on flags and the env vars;unsupported_security_globalrejects it like--production.FALLOW_REVIEW_GUIDANCE: default-off toggle for collapsed per-finding guidance inreview-github/review-gitlabinline comments. Truthy values are1,true,yes, andon.FALLOW_TELEMETRY: opt-in telemetry mode.off/0/false/disabled,on/1/true/enabled, orinspect/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 refusesfallow telemetry enable. Top precedence alongsideDO_NOT_TRACK.FALLOW_TELEMETRY_DEBUG: truthy is an alias forFALLOW_TELEMETRY=inspect.FALLOW_AGENT_SOURCE: normalized agent vendor for telemetry classification. Allowlist:codex,claude_code,cursor,copilot,opencode,aider,roo,windsurf,gemini(aliasesgemini_cli/antigravity),cline,continue,zed,goose,other_known,unknown,none. Hyphen aliases normalized; unrecognized values ignored.FALLOW_INTEGRATION_SURFACE: telemetryintegration_surfaceoverride read byintegration_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 taggedmcpinstead ofcli_json. Unknown/empty falls through to derivation. Only read when telemetry is On/Inspect.FALLOW_MCP_TOOL: telemetrymcp_tooldimension, validated CLI-side against the shared MCP tool manifest (fallow_types::mcp_manifest::MCP_TOOLS); any other value is dropped toNone(never echoed into the payload). Set by the MCP server alongsideFALLOW_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 underfallow audit. Wins overaudit.cacheMaxAgeDaysconfig field. Default 30 days.0disables 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 byaudit::resolve_cache_max_age; see issue #498.FALLOW_AUDIT_BASE: pins thefallow auditcomparison base when no--base/--changed-sinceis passed (issue #1168). Precedence inaudit::resolve_base_ref: explicit--baseflag >FALLOW_AUDIT_BASEenv >auto_detect_base_ref. The escape hatch exists because the generated agent gate (fallow hooks install --target agent,setup_hooks/fallow-gate.sh) runs barefallow auditwith 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 byparse_audit_base_override(trim + reject empty/whitespace-only); a non-empty value isvalidate_git_ref'd and a malformed value is a LOUD exit 2 (unlike the lenient cache-age env), mirroring--base.auto_detect_base_refitself resolves to agit merge-baseSHA 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 localmain/masterbranch when there is noorigin(air-gapped repos). The bug it fixes: the oldauto_detect_base_branchdiscovered the default viaorigin/HEADbut returned the BARE namemain, which git resolves to the stale LOCALrefs/heads/mainon worktree checkouts.AuditResult.base_description(NOT serialized;AuditResulthas no serde derive, JSON is hand-built inaudit_output.rs) carries the provenance for the human scope line, short-SHA'd viaaudit_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.tsfiles are exempt. The--max-file-sizeglobal flag wins over the env var. Held inruntime_support::MAX_FILE_SIZE_OVERRIDE(aOnceLockset once frommain()after parse, not threaded through the 10load_config_for_analysiscallers), read byresolve_max_file_size_mb, applied POST-resolve ontoResolvedConfig.max_file_size_bytesviafallow_config::resolve_max_file_size_bytes. Skipped files surface inworkspace_diagnostics[](kind: skipped-large-file) and an aggregated stderr warn. There is intentionally NO.fallowrc.jsonconfig field yet (deferred: aFallowConfigfield 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 tooff/0/false/no/disabledto suppress the command-levelnext_steps[]array in JSON output and the humanNext:line. Default on. Parsed byreport::suggestions::suggestions_enabled(pure helpersuggestions_enabled_fromis unit-tested without env mutation). Registered in thefallow schemaenv-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_fallowdoes not strip env), so it disablesnext_stepson MCP responses too.
Structured JSON errors on stdout when --format json is active: {"error": true, "message": "...", "exit_code": 2}