Skip to main content

fallow_cli/
lib.rs

1#![expect(
2    clippy::print_stdout,
3    clippy::print_stderr,
4    reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7    test,
8    allow(
9        clippy::unwrap_used,
10        clippy::expect_used,
11        reason = "tests use unwrap and expect to keep fixture setup concise"
12    )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_decision_surface;
27mod audit_focus;
28mod audit_walkthrough;
29mod base_worktree;
30/// Re-exported for integration tests so they hash reusable-cache roots through
31/// the exact production path (`dunce` canonicalization + platform path-identity
32/// bytes) rather than an approximation that diverges on Windows.
33pub use base_worktree::canonical_root_hash;
34mod walkthrough_state;
35use fallow_engine::baseline;
36mod cache_notice;
37mod check;
38mod ci;
39mod ci_template;
40mod cli_format;
41mod cli_hooks;
42mod cli_impact;
43mod cli_production;
44mod cli_report;
45mod cli_startup;
46pub use fallow_engine::codeowners;
47mod combined;
48mod config;
49mod coverage;
50mod dupes;
51pub mod explain;
52mod fix;
53mod flags;
54mod guard;
55mod health;
56mod impact;
57mod init;
58mod inspect;
59mod json_style;
60mod license;
61mod list;
62mod migrate;
63mod onboarding;
64#[cfg(test)]
65mod output_envelope;
66mod output_runtime;
67mod path_util;
68mod plugin_check;
69mod rayon_pool;
70mod regression;
71pub mod report;
72mod rule_pack;
73mod runtime_support;
74mod schema;
75mod security;
76mod security_help;
77mod setup_hooks;
78mod signal;
79mod suppressions;
80mod task_matrix;
81mod telemetry;
82mod trace_chain;
83mod update_check;
84use fallow_engine::validate;
85use fallow_engine::vital_signs;
86mod cli_telemetry;
87mod watch;
88
89use check::{CheckOptions, IssueFilters, TraceOptions};
90/// Structured error output for CLI and JSON formats.
91pub mod error;
92#[cfg(test)]
93use cli_format::parse_format_arg;
94use cli_format::{Format, FormatConfig};
95use cli_hooks::{HooksCli, run_hooks_command};
96use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
97use cli_production::{ProductionModes, resolve_production_modes};
98#[cfg(test)]
99use cli_startup::build_tracing_filter;
100use cli_startup::{
101    bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
102    run_pre_dispatch_checks, setup_tracing, validate_inputs,
103};
104#[cfg(test)]
105use cli_telemetry::TelemetryRun;
106#[cfg(test)]
107use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
108use cli_telemetry::{record_run_epilogue, start_telemetry_run};
109use dupes::{DupesMode, DupesOptions};
110use error::emit_error;
111use health::{HealthOptions, SortBy};
112use list::ListOptions;
113pub use runtime_support::{AnalysisKind, GroupBy};
114pub(crate) use runtime_support::{
115    ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
116    load_config_for_analysis,
117};
118#[cfg(test)]
119use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
120use security_help::{render_security_help, security_help_target};
121
122const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
123
124const TOP_LEVEL_HELP_TEMPLATE: &str =
125    "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
126
127const TOP_LEVEL_AFTER_HELP: &str = "\
128Analysis:
129  dead-code      Analyze unused code, dependency hygiene, and architecture cycles
130  dupes          Find copy-paste and structural code duplication
131  health         Analyze complexity, maintainability, hotspots, and coverage gaps
132  flags          Detect feature flag usage patterns
133  security       Surface local security candidates for agent verification (opt-in)
134  audit          Review changed files for dead code, complexity, duplication, and styling
135
136Workflow:
137  watch          Re-run analysis as files change
138  fix            Auto-fix safe unused-code findings
139
140Project inspection:
141  list           List discovered files, entry points, plugins, boundaries, and workspaces
142  inspect        Inspect one file or exported symbol as a bundled evidence query
143  workspaces     Show monorepo workspace discovery diagnostics
144  explain        Explain one issue type without running analysis
145  suppressions   List active fallow-ignore suppression markers
146  impact         Show what fallow has done for you (opt-in, local-only)
147
148Setup and configuration:
149  init              Create a fallow config, optionally with a Git hook
150  audit-cache       Maintain reusable audit base-snapshot caches
151  recommend         Recommend a project-tailored config for an agent to author
152  migrate           Migrate knip, jscpd, or stylelint config to fallow
153  config            Show the resolved config and loaded config file
154  config-schema     Print the fallow config JSON Schema
155  plugin-schema     Print the external plugin JSON Schema
156  plugin-check      Dry-run external plugins and report what they seed
157  rule-pack-schema  Print the rule pack JSON Schema
158
159Automation and CI:
160  ci             Build PR/MR feedback envelopes
161  ci-template    Print or vendor CI integration templates
162  report         Re-render a saved --format json results file (GitHub formats)
163  hooks          Install or remove fallow-managed Git and agent hooks
164  setup-hooks    Legacy agent-hook installer
165
166Runtime coverage:
167  coverage       Set up or analyze runtime coverage data
168  license        Manage the paid-feature license
169  telemetry      Manage opt-in product telemetry
170
171Reference:
172  schema         Dump the CLI interface as machine-readable JSON
173  help           Print this message or the help of a command
174
175When no command is given, fallow runs dead-code + dupes + health together.
176Use --only/--skip to select specific analyses.
177
178When the agent is about to...
179  delete an \"unused\" export or file        fallow dead-code --trace <file>:<export>
180  delete an \"unused\" dependency            fallow dead-code --trace-dependency <name>
181  commit or open a PR                      fallow audit --base <ref>
182  prioritize refactoring                   fallow health --hotspots --targets
183  ask who owns code                        fallow health --ownership
184  check untested-but-reachable code        fallow health --coverage-gaps
185  consolidate duplication                  fallow dupes --trace dup:<fingerprint>
186  find feature flags                       fallow flags
187  check architecture rules before editing  fallow guard <files>
188  surface security candidates              fallow security
189  inspect a target before editing          fallow inspect --file <path>
190  understand a finding                     fallow explain <issue-type>
191  scope a monorepo                         --workspace <glob> / --changed-workspaces <ref>";
192
193#[derive(Parser)]
194#[command(
195    name = "fallow",
196    about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
197    version,
198    disable_version_flag = true,
199    help_template = TOP_LEVEL_HELP_TEMPLATE,
200    after_help = TOP_LEVEL_AFTER_HELP
201)]
202struct Cli {
203    #[command(subcommand)]
204    command: Option<Command>,
205
206    /// Print version.
207    /// Accepts `-v`, `-V`, and `--version`; TS/JS tooling (node, npm, pnpm,
208    /// yarn, bun, tsc) uses `-v`, while `-V` matches knip/oxlint/biome.
209    #[arg(
210        short = 'v',
211        visible_short_alias = 'V',
212        long = "version",
213        action = clap::ArgAction::Version
214    )]
215    version: Option<bool>,
216
217    /// Project root directory
218    #[arg(short, long, global = true)]
219    root: Option<PathBuf>,
220
221    /// Path to config file (.fallowrc.json, .fallowrc.jsonc, fallow.toml, or .fallow.toml)
222    #[arg(short, long, global = true)]
223    config: Option<PathBuf>,
224
225    /// Allow trusted config files to extend HTTPS URLs
226    #[arg(long, global = true)]
227    allow_remote_extends: bool,
228
229    /// Output format (alias: --output)
230    #[arg(
231        short,
232        long,
233        visible_alias = "output",
234        global = true,
235        default_value = "human"
236    )]
237    format: Format,
238
239    /// Indent JSON output for manual inspection. Requires the final output format to be JSON.
240    #[arg(long, global = true)]
241    pretty: bool,
242
243    /// Suppress progress output
244    #[arg(short, long, global = true)]
245    quiet: bool,
246
247    /// Disable incremental caching
248    #[arg(long, global = true)]
249    no_cache: bool,
250
251    /// Number of parser threads
252    #[arg(long, global = true)]
253    threads: Option<usize>,
254
255    /// Only report issues in files changed since this git ref (e.g., main, HEAD~5)
256    #[arg(long, visible_alias = "base", global = true)]
257    changed_since: Option<String>,
258
259    /// Unified diff for line-level scoping.
260    /// Use `-` to read from stdin. Project-level findings still bypass this
261    /// filter. When both this and `--changed-since` are set, the diff filter
262    /// wins for finding scope while `--changed-since` still drives file discovery.
263    #[arg(long = "diff-file", value_name = "PATH", global = true)]
264    diff_file: Option<PathBuf>,
265
266    /// Read the unified diff from stdin.
267    /// Equivalent to `--diff-file -`.
268    #[arg(long = "diff-stdin", global = true)]
269    diff_stdin: bool,
270
271    /// Import change history from a `fallow-churn/v1` JSON file instead of `git
272    /// log`, powering hotspots, ownership, and bus-factor on projects with no
273    /// git repository (Yandex Arc, Mercurial, Perforce). A small wrapper
274    /// translates your VCS log into the contract. Resolved relative to `--root`.
275    /// Affects `health --hotspots` / `--ownership` / `--targets` only; `audit`,
276    /// `impact`, and `--changed-since` still require git.
277    #[arg(long = "churn-file", value_name = "PATH", global = true)]
278    churn_file: Option<PathBuf>,
279
280    /// Skip source files larger than this many megabytes (default 5) instead of
281    /// parsing them, guarding against the out-of-memory blowup a single
282    /// multi-MB generated/vendored/bundled file causes on large repos. Use `0`
283    /// for no limit. Declaration files (`.d.ts`) are always analyzed. Skipped
284    /// files are reported and excluded from every analysis. Also settable via
285    /// `FALLOW_MAX_FILE_SIZE`.
286    #[arg(long = "max-file-size", value_name = "MB", global = true)]
287    max_file_size: Option<u32>,
288
289    /// Compare against a previously saved baseline file
290    #[arg(long, global = true)]
291    baseline: Option<PathBuf>,
292
293    /// Correlate this run with a previous telemetry analysis run.
294    ///
295    /// Used only for opt-in telemetry follow-up measurement. The value is not
296    /// interpreted as a path, repository, package, or user identifier. Hidden
297    /// from `--help`; agents receive the correlation token from JSON output.
298    #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
299    parent_run: Option<String>,
300
301    /// Save the current results as a baseline file
302    #[arg(long, global = true)]
303    save_baseline: Option<PathBuf>,
304
305    /// Production mode: exclude test/story/dev files, only start/build scripts,
306    /// report type-only dependencies
307    #[arg(long, global = true)]
308    production: bool,
309
310    /// Force production mode OFF for every analysis, overriding a project
311    /// config's `production: true` (and `FALLOW_PRODUCTION`). Conflicts with
312    /// `--production`.
313    #[arg(long = "no-production", global = true, conflicts_with = "production")]
314    no_production: bool,
315
316    /// Run dead-code analysis in production mode when using bare combined mode.
317    #[arg(long = "production-dead-code")]
318    production_dead_code: bool,
319
320    /// Run health analysis in production mode when using bare combined mode.
321    #[arg(long = "production-health")]
322    production_health: bool,
323
324    /// Run duplication analysis in production mode when using bare combined mode.
325    #[arg(long = "production-dupes")]
326    production_dupes: bool,
327
328    /// Scope output to selected workspaces.
329    /// Accepts exact names, glob patterns, and `!`-prefixed negations.
330    /// Values can be comma-separated or repeated.
331    #[arg(short, long, global = true, value_delimiter = ',')]
332    workspace: Option<Vec<String>>,
333
334    /// Scope output to workspaces touched since the given git ref.
335    /// Git is required. Mutually exclusive with `--workspace`.
336    #[arg(long, global = true, value_name = "REF")]
337    changed_workspaces: Option<String>,
338
339    /// Group output by owner or by directory.
340    #[arg(long, global = true)]
341    group_by: Option<GroupBy>,
342
343    /// Show pipeline performance timing breakdown
344    #[arg(long, global = true)]
345    performance: bool,
346
347    /// Include metric definitions and rule descriptions in output.
348    #[arg(long, global = true)]
349    explain: bool,
350
351    /// Show a per-pattern breakdown for default duplicate ignores.
352    #[arg(long, global = true)]
353    explain_skipped: bool,
354
355    /// Show only category counts without individual items
356    #[arg(long, global = true)]
357    summary: bool,
358
359    /// CI mode: equivalent to --format sarif --fail-on-issues --quiet
360    #[arg(long, global = true)]
361    ci: bool,
362
363    /// Exit with code 1 if issues are found
364    #[arg(long, global = true)]
365    fail_on_issues: bool,
366
367    /// Write SARIF output to a file (in addition to the primary --format output)
368    #[arg(long, global = true, value_name = "PATH")]
369    sarif_file: Option<PathBuf>,
370
371    /// Write the report to a file instead of stdout, for any --format (no ANSI
372    /// codes). Useful on large projects where the terminal scrollback truncates
373    /// the top. Progress and the confirmation stay on stderr.
374    #[arg(short = 'o', long, global = true, value_name = "PATH")]
375    output_file: Option<PathBuf>,
376
377    /// Prefix prepended to every path in the CI-facing formats
378    /// (`github-annotations`, `github-summary`, `codeclimate`,
379    /// `review-github`, `review-gitlab`). CI platforms address files by
380    /// repository-root-relative path, so when the analyzed project lives in a
381    /// subdirectory (e.g. `packages/app/`), paths need that offset. fallow
382    /// detects the offset via the git toplevel automatically; this flag
383    /// overrides the detection. Pass an empty string to disable rebasing and
384    /// emit paths relative to `--root`.
385    #[arg(
386        long = "report-path-prefix",
387        visible_alias = "annotations-path-prefix",
388        global = true,
389        value_name = "PREFIX"
390    )]
391    report_path_prefix: Option<String>,
392
393    /// Fail if issue count increased beyond tolerance compared to a regression baseline.
394    #[arg(long, global = true)]
395    fail_on_regression: bool,
396
397    /// Allowed issue count increase before a regression is flagged.
398    #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
399    tolerance: String,
400
401    /// Path to the regression baseline file.
402    #[arg(long, global = true, value_name = "PATH")]
403    regression_baseline: Option<PathBuf>,
404
405    /// Save the current issue counts as a regression baseline. Omit PATH to
406    /// update regression.baseline in the discovered fallow config, or create
407    /// .fallowrc.json when none exists. Provide PATH to write a standalone file.
408    #[expect(
409        clippy::option_option,
410        reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
411    )]
412    #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
413    save_regression_baseline: Option<Option<String>>,
414
415    /// Run only specific analyses when no subcommand is given.
416    #[arg(long, value_delimiter = ',')]
417    only: Vec<AnalysisKind>,
418
419    /// Skip specific analyses when no subcommand is given.
420    #[arg(long, value_delimiter = ',')]
421    skip: Vec<AnalysisKind>,
422
423    /// Override duplication detection mode in combined mode.
424    #[arg(long = "dupes-mode", global = true)]
425    dupes_mode: Option<DupesMode>,
426
427    /// Override duplication threshold in combined mode.
428    #[arg(long = "dupes-threshold", global = true)]
429    dupes_threshold: Option<f64>,
430
431    /// Override the minimum token count for clones in combined mode.
432    #[arg(long = "dupes-min-tokens", global = true)]
433    dupes_min_tokens: Option<usize>,
434
435    /// Override the minimum line count for clones in combined mode.
436    #[arg(long = "dupes-min-lines", global = true)]
437    dupes_min_lines: Option<usize>,
438
439    /// Override the minimum clone occurrences in combined mode (must be >= 2).
440    #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
441    dupes_min_occurrences: Option<usize>,
442
443    /// Only report cross-directory duplicates in combined mode.
444    #[arg(long = "dupes-skip-local", global = true)]
445    dupes_skip_local: bool,
446
447    /// Enable cross-language duplicate detection in combined mode.
448    #[arg(long = "dupes-cross-language", global = true)]
449    dupes_cross_language: bool,
450
451    /// Exclude module wiring from duplicate detection in combined mode
452    /// (default). Pass `--dupes-no-ignore-imports` to count it again.
453    #[arg(long = "dupes-ignore-imports", global = true)]
454    dupes_ignore_imports: bool,
455
456    /// Count module wiring as clone candidates in combined mode (opt out of the
457    /// default exclusion).
458    #[arg(
459        long = "dupes-no-ignore-imports",
460        global = true,
461        conflicts_with = "dupes_ignore_imports"
462    )]
463    dupes_no_ignore_imports: bool,
464
465    /// Compute health score in combined mode.
466    #[arg(long)]
467    score: bool,
468
469    /// Compare current health metrics against the most recent saved snapshot.
470    #[arg(long)]
471    trend: bool,
472
473    /// Save a vital signs snapshot for trend tracking in combined mode.
474    /// Provide a path or omit for the default `.fallow/snapshots/` location.
475    #[expect(
476        clippy::option_option,
477        reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
478    )]
479    #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
480    save_snapshot: Option<Option<String>>,
481
482    /// Path to Istanbul coverage data for exact CRAP scores in combined mode.
483    /// Also settable via `FALLOW_COVERAGE` or `health.coverage`.
484    #[arg(long, value_name = "PATH")]
485    coverage: Option<PathBuf>,
486
487    /// Absolute prefix to strip from Istanbul file paths in combined mode.
488    /// Also settable via `FALLOW_COVERAGE_ROOT` or `health.coverageRoot`.
489    #[arg(long = "coverage-root", value_name = "PATH")]
490    coverage_root: Option<PathBuf>,
491
492    /// Report unused exports in entry files instead of auto-marking them as used.
493    #[arg(long, global = true)]
494    include_entry_exports: bool,
495}
496
497#[derive(Subcommand)]
498enum Command {
499    /// Analyze project for unused code and circular dependencies
500    #[command(name = "dead-code", alias = "check")]
501    Check {
502        /// Only report unused files
503        #[arg(long)]
504        unused_files: bool,
505
506        /// Only report unused exports
507        #[arg(long)]
508        unused_exports: bool,
509
510        /// Only report unused dependencies
511        #[arg(long)]
512        unused_deps: bool,
513
514        /// Only report unused type exports
515        #[arg(long)]
516        unused_types: bool,
517
518        /// Opt in to private type leak API hygiene findings and only report that issue type
519        #[arg(long)]
520        private_type_leaks: bool,
521
522        /// Only report unused enum members
523        #[arg(long)]
524        unused_enum_members: bool,
525
526        /// Only report unused class members
527        #[arg(long)]
528        unused_class_members: bool,
529
530        /// Only report unused store members
531        #[arg(long)]
532        unused_store_members: bool,
533
534        /// Only report unprovided injects
535        #[arg(long)]
536        unprovided_injects: bool,
537
538        /// Only report unrendered components
539        #[arg(long)]
540        unrendered_components: bool,
541
542        /// Only report unused component props
543        #[arg(long)]
544        unused_component_props: bool,
545
546        /// Only report unused component emits
547        #[arg(long)]
548        unused_component_emits: bool,
549
550        /// Only report unused component inputs
551        #[arg(long)]
552        unused_component_inputs: bool,
553
554        /// Only report unused component outputs
555        #[arg(long)]
556        unused_component_outputs: bool,
557
558        /// Only report unused Svelte dispatched events
559        #[arg(long)]
560        unused_svelte_events: bool,
561
562        /// Only report unused server actions
563        #[arg(long)]
564        unused_server_actions: bool,
565
566        /// Only report unused SvelteKit load() data keys
567        #[arg(long)]
568        unused_load_data_keys: bool,
569
570        /// Only report unresolved imports
571        #[arg(long)]
572        unresolved_imports: bool,
573
574        /// Only report unlisted dependencies
575        #[arg(long)]
576        unlisted_deps: bool,
577
578        /// Only report duplicate exports
579        #[arg(long)]
580        duplicate_exports: bool,
581
582        /// Only report circular dependencies
583        #[arg(long)]
584        circular_deps: bool,
585
586        /// Only report re-export cycles
587        #[arg(long)]
588        re_export_cycles: bool,
589
590        /// Only report boundary violations
591        #[arg(long)]
592        boundary_violations: bool,
593
594        /// Only report rule-pack policy violations
595        #[arg(long)]
596        policy_violations: bool,
597
598        /// Only report stale suppressions
599        #[arg(long)]
600        stale_suppressions: bool,
601
602        /// Only report unused pnpm catalog entries
603        #[arg(long)]
604        unused_catalog_entries: bool,
605
606        /// Only report empty pnpm catalog groups
607        #[arg(long)]
608        empty_catalog_groups: bool,
609
610        /// Only report unresolved pnpm catalog references
611        #[arg(long)]
612        unresolved_catalog_references: bool,
613
614        /// Only report unused pnpm dependency overrides
615        #[arg(long)]
616        unused_dependency_overrides: bool,
617
618        /// Only report misconfigured pnpm dependency overrides
619        #[arg(long)]
620        misconfigured_dependency_overrides: bool,
621
622        /// Also run duplication analysis and cross-reference with dead code
623        #[arg(long)]
624        include_dupes: bool,
625
626        /// Trace why an export is used/unused (format: `FILE:EXPORT_NAME`)
627        #[arg(long, value_name = "FILE:EXPORT")]
628        trace: Option<String>,
629
630        /// Trace all edges for a file (imports, exports, importers)
631        #[arg(long, value_name = "PATH")]
632        trace_file: Option<String>,
633
634        /// Trace where a dependency is used
635        #[arg(long, value_name = "PACKAGE")]
636        trace_dependency: Option<String>,
637
638        /// Compute the impact closure for a file (the transitive
639        /// affected-but-not-in-diff set + coordination gap). Walks reverse-deps
640        /// and re-export chains; powers the `inspect_target` MCP tool.
641        #[arg(long, value_name = "PATH")]
642        impact_closure: Option<String>,
643
644        /// Show only the top N items per category
645        #[arg(long)]
646        top: Option<usize>,
647
648        /// Only report issues in the specified file(s). Accepts multiple values.
649        /// The full project graph is still built, but only issues in matching files
650        /// are reported. Useful for lint-staged pre-commit hooks.
651        #[arg(long, value_name = "PATH")]
652        file: Vec<std::path::PathBuf>,
653    },
654
655    /// Watch for changes and re-run analysis
656    Watch {
657        /// Don't clear the screen between re-analyses
658        #[arg(long)]
659        no_clear: bool,
660    },
661
662    /// Inspect one file or exported symbol as a bundled evidence query
663    Inspect {
664        /// File to inspect.
665        #[arg(
666            long,
667            value_name = "PATH",
668            conflicts_with = "symbol",
669            required_unless_present = "symbol"
670        )]
671        file: Option<String>,
672
673        /// Exported symbol to inspect, formatted as FILE:EXPORT.
674        #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
675        symbol: Option<String>,
676
677        /// OPT-IN: also attach the best-effort symbol-level call chain
678        /// (`fallow trace`) as the `symbol_chain` evidence section. Only
679        /// meaningful for a `--symbol` target. Default off (best-effort,
680        /// syntactic, OFF the ranked path).
681        #[arg(long)]
682        symbol_chain: bool,
683
684        /// OPT-IN: attach target-level git churn evidence from the health
685        /// hotspot subsystem. Default off to avoid git-history latency.
686        #[arg(long)]
687        churn: bool,
688    },
689
690    /// Trace a symbol's call chain (best-effort, syntactic; OFF the ranked path)
691    ///
692    /// Walks callers UP (modules that import the symbol) and callees DOWN
693    /// (import-symbol edges + intra-module call sites) via the module graph,
694    /// bounded by `--depth`. Symbol-level chains are labeled best-effort per
695    /// ADR-001: resolved-vs-unresolved callees are reported honestly, never
696    /// silently dropped. The result is its OWN surface, NOT folded into the
697    /// ranked brief and NEVER an input to the focus map / ranking.
698    Trace {
699        /// Target symbol, formatted as FILE:SYMBOL (e.g. src/utils.ts:formatDate).
700        #[arg(value_name = "FILE:SYMBOL")]
701        symbol: String,
702
703        /// Walk UP to callers (modules that import the symbol). When neither
704        /// `--callers` nor `--callees` is set, both directions are walked.
705        #[arg(long)]
706        callers: bool,
707
708        /// Walk DOWN to callees (the symbol's module's import-symbol edges plus
709        /// unresolved call sites). When neither flag is set, both are walked.
710        #[arg(long)]
711        callees: bool,
712
713        /// Chain depth bound for both directions (default 2). Symbol-level is
714        /// best-effort, so a shallow bound keeps the trace legible.
715        #[arg(long, value_name = "N")]
716        depth: Option<u32>,
717    },
718
719    /// Auto-fix issues: remove unused exports, dependencies, and enum
720    /// members; add duplicate-export rules to a fallow config file.
721    ///
722    /// When no fallow config exists outside a monorepo subpackage, a
723    /// fresh `.fallowrc.json` is created from the same scaffolding
724    /// `fallow init` would emit (framework detection, `$schema`,
725    /// `entry`, etc.) and the duplicate-export rules are layered on
726    /// top. Inside a monorepo subpackage the create-fallback refuses
727    /// and points at the workspace root. Pass `--no-create-config` to
728    /// opt out of the create-fallback (recommended for pre-commit
729    /// hooks, CI bots, and `fallow watch`).
730    ///
731    /// Use `--dry-run` to preview source-file edits and config-file
732    /// diffs without writing.
733    Fix {
734        /// Dry run, show what would be changed without modifying files
735        #[arg(long)]
736        dry_run: bool,
737
738        /// Skip confirmation prompt (required in non-TTY environments like CI or AI agents)
739        #[arg(long, alias = "force")]
740        yes: bool,
741
742        /// Refuse to create a new fallow config file when none exists.
743        /// Use this from pre-commit hooks, CI bots, and `fallow watch`
744        /// where silently materialising a new top-level config file would
745        /// surprise the user. The duplicate-export config-add path is
746        /// skipped with an explanatory message; source-file edits proceed
747        /// normally.
748        #[arg(long)]
749        no_create_config: bool,
750    },
751
752    /// Initialize a .fallowrc.json configuration file, AGENTS.md guide, or git
753    /// pre-commit hook. Use `.fallowrc.jsonc` for editor-native JSON-with-comments
754    /// support; both extensions are auto-discovered.
755    ///
756    /// `--hooks` scaffolds a shell-level Git pre-commit hook under
757    /// `.git/hooks/` that runs fallow on changed files. The clearer hook
758    /// namespace is `fallow hooks install --target git`; `init --hooks`
759    /// remains as a convenience during project initialization.
760    Init {
761        /// Generate TOML instead of JSONC
762        #[arg(long)]
763        toml: bool,
764
765        /// Scaffold a starter AGENTS.md guidance file for coding agents
766        #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
767        agents: bool,
768
769        /// Scaffold a shell-level pre-commit git hook in `.git/hooks/` that
770        /// runs fallow on changed files. Alias for
771        /// `fallow hooks install --target git`.
772        #[arg(long)]
773        hooks: bool,
774
775        /// Fallback base branch/ref for the pre-commit hook when no upstream is set
776        #[arg(long, requires = "hooks")]
777        branch: Option<String>,
778
779        /// Record that this project deliberately stays unconfigured: persists a
780        /// decline so the first-contact setup hint and the `setup` next-step
781        /// stop appearing here. Writes no config file; idempotent
782        #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
783        decline: bool,
784    },
785
786    /// Install or remove fallow-managed Git and agent hooks.
787    ///
788    /// Use `fallow hooks install --target git` for a shell-level Git
789    /// pre-commit hook. Use `fallow hooks install --target agent` for a
790    /// Claude Code / Codex gate that blocks agent `git commit` / `git push`
791    /// commands until `fallow audit` passes.
792    Hooks {
793        #[command(subcommand)]
794        subcommand: HooksCli,
795    },
796
797    /// CI helpers for PR/MR feedback envelopes.
798    Ci {
799        #[command(subcommand)]
800        subcommand: CiCli,
801    },
802
803    /// Print the JSON Schema for fallow configuration files
804    ConfigSchema,
805
806    /// Print the JSON Schema for external plugin files
807    PluginSchema,
808
809    /// Dry-run external plugins: report what each activated and seeded
810    PluginCheck,
811
812    /// Print the JSON Schema for rule pack files
813    RulePackSchema,
814
815    /// Manage declarative rule packs (policy-as-code)
816    RulePack {
817        #[command(subcommand)]
818        subcommand: RulePackCli,
819    },
820
821    /// Show which architecture rules apply to files before changing them.
822    Guard {
823        /// Files to report on (root-relative or absolute; may not exist yet)
824        #[arg(required = true, num_args = 1..)]
825        files: Vec<String>,
826    },
827
828    /// Show the resolved config and which config file was loaded
829    ///
830    /// Walks up from the project root looking for `.fallowrc.json`,
831    /// `.fallowrc.jsonc`, `fallow.toml`, or `.fallow.toml`, resolves `extends`, and prints
832    /// the final config as JSON. Use `--path` to print only the config
833    /// file path (useful in shell scripts). The default view always exits 0:
834    /// it prints the loaded config, or, on a zero-config project, the effective
835    /// defaults (fully supported). `--path` exits 3 when no config file exists,
836    /// since there is no path to report.
837    ///
838    /// Precedence is first-match-wins per directory, in the order
839    /// `.fallowrc.json` > `.fallowrc.jsonc` > `fallow.toml` > `.fallow.toml`,
840    /// walking up to the workspace root. `.fallowrc.json` accepts JSONC
841    /// (comments and trailing commas); `.fallowrc.jsonc` is identical in
842    /// behavior, the extension only signals to editors that comments are
843    /// expected. If two config files coexist in one directory, fallow loads the
844    /// higher-precedence one and warns on stderr naming the file it ignored.
845    Config {
846        /// Print only the config file path (one line, no JSON)
847        #[arg(long)]
848        path: bool,
849    },
850
851    /// Recommend a project-tailored config for an agent to author.
852    ///
853    /// Read-only. Inspects the project (frameworks, workspace layout, tooling)
854    /// and emits what fallow detected, a safe proposed config, and a list of
855    /// decisions split into auto (decided from detection), default (a disclosed
856    /// overridable default), and taste (a genuinely subjective choice surfaced
857    /// to the user as an open question). Honors `--root` and `--format`.
858    Recommend,
859
860    /// List discovered entry points, files, plugins, boundaries, and workspaces.
861    List {
862        /// Show entry points
863        #[arg(long)]
864        entry_points: bool,
865
866        /// Show all discovered files
867        #[arg(long)]
868        files: bool,
869
870        /// Show active plugins
871        #[arg(long)]
872        plugins: bool,
873
874        /// Show architecture boundary zones, rules, and per-zone file counts
875        #[arg(long)]
876        boundaries: bool,
877
878        /// Show monorepo workspaces and any workspace-discovery diagnostics
879        /// (malformed package.json, unreachable glob matches, missing
880        /// tsconfig references).
881        #[arg(long)]
882        workspaces: bool,
883    },
884
885    /// Show monorepo workspaces and any workspace-discovery diagnostics.
886    ///
887    /// Equivalent to `fallow list --workspaces`. Use this dedicated form
888    /// when introspecting only the workspace topology (other `list`
889    /// sections stay hidden).
890    Workspaces,
891
892    /// Find code duplication / clones across the project
893    Dupes {
894        /// Detection mode: strict, mild, weak, or semantic
895        /// (defaults to the value in `.fallowrc.jsonc`, or `mild` if unset).
896        #[arg(long)]
897        mode: Option<DupesMode>,
898
899        /// Minimum token count for a clone
900        /// (defaults to the value in `.fallowrc.jsonc`, or `50` if unset).
901        #[arg(long)]
902        min_tokens: Option<usize>,
903
904        /// Minimum line count for a clone
905        /// (defaults to the value in `.fallowrc.jsonc`, or `5` if unset).
906        #[arg(long)]
907        min_lines: Option<usize>,
908
909        /// Minimum number of occurrences before a clone group is reported.
910        /// Raise to focus on widespread copy-paste worth refactoring and skip
911        /// pair-only clones.
912        /// (defaults to the value in `.fallowrc.jsonc`, or `2` if unset).
913        #[arg(long, value_parser = parse_min_occurrences)]
914        min_occurrences: Option<usize>,
915
916        /// Fail if duplication exceeds this percentage (0 = no limit)
917        /// (defaults to the value in `.fallowrc.jsonc`, or `0` if unset).
918        #[arg(long)]
919        threshold: Option<f64>,
920
921        /// Only report cross-directory duplicates
922        #[arg(long)]
923        skip_local: bool,
924
925        /// Enable cross-language detection (strip TS type annotations for TS↔JS matching)
926        #[arg(long)]
927        cross_language: bool,
928
929        /// Exclude module wiring from clone detection (default; covers imports,
930        /// re-exports, and top-level static require bindings). Pass
931        /// `--no-ignore-imports` to count it again.
932        #[arg(long)]
933        ignore_imports: bool,
934
935        /// Count module wiring as clone candidates (opt out of the default
936        /// exclusion).
937        #[arg(long, conflicts_with = "ignore_imports")]
938        no_ignore_imports: bool,
939
940        /// Show only the N most-duplicated clone groups (sorted by instance
941        /// count descending, then line count descending)
942        #[arg(long)]
943        top: Option<usize>,
944
945        /// Trace all clones at a specific location (format: `FILE:LINE`)
946        #[arg(long, value_name = "FILE:LINE")]
947        trace: Option<String>,
948    },
949
950    /// Analyze function complexity (cyclomatic + cognitive)
951    ///
952    /// By default, shows all existing sections: health score, complexity findings,
953    /// file scores, hotspots, and refactoring targets. When any section flag is
954    /// specified, only those sections are shown.
955    Health {
956        /// Maximum cyclomatic complexity threshold (overrides config)
957        #[arg(long)]
958        max_cyclomatic: Option<u16>,
959
960        /// Maximum cognitive complexity threshold (overrides config)
961        #[arg(long)]
962        max_cognitive: Option<u16>,
963
964        /// Maximum CRAP score threshold (overrides config, default 30.0).
965        /// Functions meeting or exceeding this score are reported alongside
966        /// complexity findings. Pair with `--coverage` for accurate scoring.
967        #[arg(long)]
968        max_crap: Option<f64>,
969
970        /// Show only the N most complex functions
971        #[arg(long)]
972        top: Option<usize>,
973
974        /// Sort by: cyclomatic (default), cognitive, lines, or severity
975        #[arg(long, default_value = "cyclomatic")]
976        sort: SortBy,
977
978        /// Show only complexity findings (functions exceeding thresholds).
979        /// By default all sections are shown; use this to select only complexity.
980        #[arg(long)]
981        complexity: bool,
982
983        /// Include the per-decision-point complexity breakdown (`contributions[]`)
984        /// on each complexity finding in `--format json` output. Each entry names
985        /// the construct (if, else-if, loop, boolean operator, ...) and its
986        /// cyclomatic/cognitive weight, so a consumer can explain WHY a function
987        /// scored high. Used by the VS Code inline editor breakdown. Off by
988        /// default to keep CI/default output lean.
989        #[arg(long)]
990        complexity_breakdown: bool,
991
992        /// Show only per-file health scores (fan-in, fan-out, dead code ratio, maintainability index).
993        /// Requires full analysis pipeline (graph + dead code detection).
994        /// Sorted by risk-aware triage concern: lower MI and higher CRAP risk first.
995        /// --sort and --baseline apply to complexity findings only, not file scores.
996        #[arg(long)]
997        file_scores: bool,
998
999        /// Show only static test coverage gaps: runtime files and exports with no
1000        /// dependency path from any discovered test root. Requires full analysis pipeline.
1001        #[arg(long)]
1002        coverage_gaps: bool,
1003
1004        /// Show only hotspots: files that are both complex and frequently changing.
1005        /// Combines git churn history with complexity data. Requires a git repository.
1006        #[arg(long)]
1007        hotspots: bool,
1008
1009        /// Attach ownership signals to hotspot entries: bus factor, contributor
1010        /// count, declared CODEOWNERS owner, and ownership drift. Implies
1011        /// `--hotspots`. Requires a git repository.
1012        #[arg(long)]
1013        ownership: bool,
1014
1015        /// Privacy mode for author emails emitted with `--ownership`.
1016        /// Defaults to `handle` (local-part only). Use `raw` for OSS repos
1017        /// where authors are public, or `anonymized` to emit non-reversible
1018        /// pseudonyms in regulated environments. Implies `--ownership`.
1019        #[arg(long, value_name = "MODE", value_enum)]
1020        ownership_emails: Option<EmailModeArg>,
1021
1022        /// Show only refactoring targets: ranked recommendations based on complexity,
1023        /// coupling, churn, and dead code signals. Requires full analysis pipeline.
1024        #[arg(long)]
1025        targets: bool,
1026
1027        /// Add structural CSS analytics: specificity hotspots, !important density,
1028        /// over-complex selectors, deep nesting, and conservative cleanup
1029        /// candidates. Standard CSS is parsed structurally; preprocessor sources
1030        /// are scanned only where fallow can avoid expanding Sass/Less semantics.
1031        #[arg(long)]
1032        css: bool,
1033
1034        /// Filter refactoring targets by effort level (low, medium, high).
1035        /// Implies --targets.
1036        #[arg(long, value_enum)]
1037        effort: Option<EffortFilter>,
1038
1039        /// Show only the project health score (0–100) with letter grade (A/B/C/D/F).
1040        /// The score is included by default when no section flags are set.
1041        #[arg(long)]
1042        score: bool,
1043
1044        /// Fail if the health score is below this threshold (0-100).
1045        /// Implies --score. The authoritative CI quality gate: when set,
1046        /// complexity findings become informational and the exit code is
1047        /// driven solely by the score (so --min-score 0 always exits 0).
1048        /// Composes with --min-severity (fails if either gate trips). Plain
1049        /// `fallow health` (no gate flag) stays advisory and exits 1 on any
1050        /// finding; for a gate on newly-introduced complexity use
1051        /// `fallow audit --gate new-only`.
1052        #[arg(long, value_name = "N")]
1053        min_score: Option<f64>,
1054
1055        /// Only exit with error for findings at or above this severity.
1056        /// Use --min-severity critical to ignore moderate/high findings in CI.
1057        /// Composes with --min-score (the run fails if either gate trips).
1058        #[arg(long, value_name = "LEVEL", value_enum)]
1059        min_severity: Option<HealthSeverityCli>,
1060
1061        /// Print the score and findings but never fail CI (always exit 0).
1062        /// Advisory mode for surfacing health in logs without blocking.
1063        /// Mutually exclusive with --min-score and --min-severity.
1064        #[arg(long)]
1065        report_only: bool,
1066
1067        /// Git history window for hotspot analysis (default: 6m).
1068        /// Accepts durations (6m, 90d, 1y, 2w) or ISO dates (2025-06-01).
1069        #[arg(long, value_name = "DURATION")]
1070        since: Option<String>,
1071
1072        /// Minimum number of commits for a file to be included in hotspot ranking (default: 3)
1073        #[arg(long, value_name = "N")]
1074        min_commits: Option<u32>,
1075
1076        /// Save a vital signs snapshot for trend tracking.
1077        /// Defaults to `.fallow/snapshots/{timestamp}.json` if no path is given.
1078        /// Forces file-scores, hotspot, and score computation for complete metrics.
1079        #[expect(
1080            clippy::option_option,
1081            reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1082        )]
1083        #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1084        save_snapshot: Option<Option<String>>,
1085
1086        /// Compare current metrics against the most recent saved snapshot.
1087        /// Reads from `.fallow/snapshots/` and shows per-metric deltas with
1088        /// directional indicators. Implies --score.
1089        #[arg(long)]
1090        trend: bool,
1091
1092        /// Path to coverage data (coverage-final.json) for exact per-function
1093        /// CRAP scores. Generate with `jest --coverage`, `vitest run --coverage
1094        /// --provider istanbul`, or any Istanbul-compatible tool. Requires
1095        /// Istanbul format (not v8/c8 native format). Accepts a single
1096        /// Istanbul coverage map JSON file or a directory containing
1097        /// coverage-final.json. Use --coverage-root when the file was generated
1098        /// in a different environment (CI runner, Docker). Affects CRAP scores
1099        /// only, not --coverage-gaps. Also configurable via FALLOW_COVERAGE env var.
1100        #[arg(long, value_name = "PATH")]
1101        coverage: Option<PathBuf>,
1102
1103        /// Absolute prefix to strip from file paths in coverage data before
1104        /// prepending the project root. Use when coverage was generated in a
1105        /// different environment (CI runner, Docker). Example: if coverage paths
1106        /// start with /home/runner/work/myapp and the project root is ./,
1107        /// pass --coverage-root /home/runner/work/myapp.
1108        #[arg(long, value_name = "PATH")]
1109        coverage_root: Option<PathBuf>,
1110
1111        /// File or directory containing runtime coverage input. Accepts a
1112        /// V8 coverage directory, a single V8 JSON file, or a single
1113        /// Istanbul coverage map JSON file (commonly coverage-final.json).
1114        #[arg(long, value_name = "PATH")]
1115        runtime_coverage: Option<PathBuf>,
1116
1117        /// Threshold for hot-path classification
1118        #[arg(long, default_value_t = 100)]
1119        min_invocations_hot: u64,
1120
1121        /// Minimum total trace volume before the sidecar allows high-confidence
1122        /// `safe_to_delete` / `review_required` verdicts. Below this the
1123        /// sidecar caps confidence at `medium` to protect against overconfident
1124        /// verdicts on new or low-traffic services. Omit to use the sidecar's
1125        /// spec default (5000).
1126        #[arg(long, value_name = "N")]
1127        min_observation_volume: Option<u32>,
1128
1129        /// Fraction of total trace count below which an invoked function is
1130        /// classified as `low_traffic` rather than `active`. Expressed as a
1131        /// decimal (e.g. `0.001` for 0.1%). Omit to use the sidecar's spec
1132        /// default (0.001).
1133        #[arg(long, value_name = "RATIO")]
1134        low_traffic_threshold: Option<f64>,
1135    },
1136
1137    /// Detect feature flag patterns in the codebase
1138    ///
1139    /// Identifies environment variable flags (process.env.FEATURE_*),
1140    /// SDK calls from common providers, and config object patterns (opt-in).
1141    /// Reports flag locations, detection confidence, and cross-reference
1142    /// with dead code findings.
1143    Flags {
1144        /// Show only the top N flags
1145        #[arg(long)]
1146        top: Option<usize>,
1147    },
1148
1149    /// List active fallow-ignore suppression markers (read-only inventory)
1150    ///
1151    /// Shows every `fallow-ignore-next-line` and `fallow-ignore-file` marker
1152    /// present in analyzed files, grouped per file with line, kind, level,
1153    /// and reason, plus project totals and a stale cross-reference against
1154    /// this run's stale-suppression findings. A governance surface, not a
1155    /// detector: always exits 0. Honors `--root`, `--format {human,json}`,
1156    /// `--workspace`, `--changed-workspaces`, `--changed-since`, and
1157    /// `--quiet`.
1158    Suppressions {
1159        /// Only list suppressions in the specified files. Accepts multiple values.
1160        #[arg(long, value_name = "PATH")]
1161        file: Vec<std::path::PathBuf>,
1162    },
1163
1164    /// Explain one fallow issue type without running an analysis.
1165    ///
1166    /// Prints the rule rationale, a worked example, fix guidance, and the
1167    /// relevant docs URL. Accepts values like `unused-export`,
1168    /// `fallow/unused-export`, `unused exports`, and `code duplication`.
1169    Explain {
1170        /// Issue type, issue label, or rule id to explain
1171        #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1172        issue_type: Vec<String>,
1173    },
1174
1175    /// Audit changed files for dead code, complexity, duplication, and styling.
1176    ///
1177    /// Purpose-built for reviewing AI-generated code and PR quality gates.
1178    /// Combines dead-code + complexity + duplication + styling scoped to
1179    /// changed files and returns a verdict (pass/warn/fail).
1180    ///
1181    /// `fallow audit` answers "will CI block this?": it gates (exit 1 on a
1182    /// fail verdict). The `review` alias plus `--brief` answer "where do I
1183    /// look?": the same analysis rendered as a deterministic orientation brief
1184    /// that ALWAYS exits 0, so a reviewer or agent can read it regardless of
1185    /// the verdict. `--format` is orthogonal to `--brief`.
1186    /// When `--changed-since`/`--base` is unset, the base is the git merge-base
1187    /// against the branch's upstream or the remote default (`origin/HEAD`,
1188    /// `origin/main`, `origin/master`); set `FALLOW_AUDIT_BASE` to pin it.
1189    /// By default, only findings introduced by the changeset affect the verdict;
1190    /// inherited findings are reported with new-vs-inherited attribution and
1191    /// individual JSON findings include `introduced: true/false`. Use
1192    /// `--gate all` or `[audit] gate = "all"` to fail on every finding in
1193    /// changed files without running the extra base-snapshot attribution pass.
1194    ///
1195    /// The global --baseline / --save-baseline flags are rejected on audit.
1196    /// Use --dead-code-baseline, --health-baseline, and --dupes-baseline
1197    /// (or their config equivalents) because each sub-analysis uses a
1198    /// different baseline format.
1199    #[command(visible_alias = "review")]
1200    Audit {
1201        /// Run dead-code analysis in production mode for this audit.
1202        #[arg(long = "production-dead-code")]
1203        production_dead_code: bool,
1204
1205        /// Run health analysis in production mode for this audit.
1206        #[arg(long = "production-health")]
1207        production_health: bool,
1208
1209        /// Run duplication analysis in production mode for this audit.
1210        #[arg(long = "production-dupes")]
1211        production_dupes: bool,
1212
1213        /// Compare dead-code issues against a saved baseline
1214        /// (produced by `fallow dead-code --save-baseline`).
1215        #[arg(long)]
1216        dead_code_baseline: Option<PathBuf>,
1217
1218        /// Compare health findings against a saved baseline
1219        /// (produced by `fallow health --save-baseline`).
1220        #[arg(long)]
1221        health_baseline: Option<PathBuf>,
1222
1223        /// Compare duplication clone groups against a saved baseline
1224        /// (produced by `fallow dupes --save-baseline`).
1225        #[arg(long)]
1226        dupes_baseline: Option<PathBuf>,
1227
1228        /// Maximum CRAP score threshold (overrides config, default 30.0).
1229        /// Functions meeting or exceeding this score cause audit to fail.
1230        /// Pair with `--coverage` for accurate scoring.
1231        #[arg(long)]
1232        max_crap: Option<f64>,
1233
1234        /// Path to Istanbul-format coverage data (coverage-final.json) for
1235        /// accurate per-function CRAP scores in the health sub-analysis. Also
1236        /// configurable via FALLOW_COVERAGE.
1237        #[arg(long, value_name = "PATH")]
1238        coverage: Option<PathBuf>,
1239
1240        /// Absolute prefix to strip from coverage data paths before CRAP matching.
1241        /// Use when coverage was generated under a different checkout root in CI or Docker.
1242        #[arg(long, value_name = "PATH")]
1243        coverage_root: Option<PathBuf>,
1244
1245        /// Disable styling analytics in audit.
1246        #[arg(long = "no-css")]
1247        no_css: bool,
1248
1249        /// Enable deep CSS analysis for audit explicitly: project-wide styling
1250        /// reachability, narrowed back to changed anchors. Deep CSS is on by
1251        /// default; use this to override `audit.cssDeep = false`.
1252        #[arg(long)]
1253        css_deep: bool,
1254
1255        /// Disable deep CSS analysis while keeping local styling analytics on.
1256        #[arg(long = "no-css-deep")]
1257        no_css_deep: bool,
1258
1259        /// Which findings affect the audit verdict.
1260        ///
1261        /// new-only (default): fail only on findings introduced by the current
1262        /// changeset. all: fail on every finding in changed files and skip
1263        /// base-snapshot attribution.
1264        #[arg(long, value_enum)]
1265        gate: Option<AuditGateArg>,
1266
1267        /// Paid runtime-coverage sidecar input. Accepts a V8 directory, a
1268        /// single V8 JSON file, or an Istanbul coverage map JSON. Spawns
1269        /// the `fallow-cov` sidecar as part of the audit pipeline so the
1270        /// `hot-path-touched` verdict surfaces alongside dead-code and
1271        /// complexity findings without requiring a second `fallow health`
1272        /// invocation in CI. License-gated; the verdict is informational
1273        /// (no exit code change) until a future `--gate hot-path-touched`
1274        /// knob lands.
1275        #[arg(long, value_name = "PATH")]
1276        runtime_coverage: Option<PathBuf>,
1277
1278        /// Threshold for hot-path classification, forwarded to the sidecar
1279        /// when `--runtime-coverage` is set.
1280        #[arg(long, default_value_t = 100)]
1281        min_invocations_hot: u64,
1282
1283        /// Internal marker identifying a gate run (e.g. `pre-commit`), set by
1284        /// the generated git hook so Fallow Impact can record a containment
1285        /// event when the gate blocks then clears. Hidden; never changes the
1286        /// verdict, exit code, or output.
1287        #[arg(long, value_name = "MARKER", hide = true)]
1288        gate_marker: Option<String>,
1289
1290        /// Render the deterministic review brief instead of the gating audit
1291        /// report. The brief answers "where do I look?" rather than "will CI
1292        /// block this?", runs the same analysis, and ALWAYS exits 0 (the
1293        /// verdict is carried informationally). Implied by `fallow review`.
1294        /// Orthogonal to `--format`.
1295        #[arg(long)]
1296        brief: bool,
1297
1298        /// Cap on the number of consequential structural decisions surfaced in
1299        /// the review brief's decision surface (the working-memory limit).
1300        /// Default 4; clamped to the 3-5 band (4 plus or minus 1). Only
1301        /// consulted on the brief path.
1302        #[arg(
1303            long,
1304            value_name = "N",
1305            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1306        )]
1307        max_decisions: usize,
1308
1309        /// Emit the agent-contract WALKTHROUGH GUIDE: the current digest
1310        /// (brief + decision surface), the review direction, the JSON schema the
1311        /// agent must return, and a deterministic graph-snapshot hash pinned into
1312        /// the digest. The digest is built from the graph only (PR prose is never
1313        /// folded in, so it is injection-resistant). Implies the brief; always
1314        /// exits 0. A thin agent skill calls this to fetch the current guide,
1315        /// produces judgment JSON, then reopens with `--walkthrough-file`.
1316        #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1317        walkthrough_guide: bool,
1318
1319        /// Ingest an agent's judgment JSON and POST-VALIDATE it against the
1320        /// LIVE graph. Rejects any judgment whose `signal_id` fallow did not emit
1321        /// (anti-hallucination); refuses the whole payload as stale when the
1322        /// echoed graph-snapshot hash no longer matches (the tree moved). The
1323        /// verifier is the graph, not a second model. Implies the brief; always
1324        /// exits 0. The agent's free-text framing is fenced as non-deterministic
1325        /// and never gates or auto-posts.
1326        #[arg(long, value_name = "PATH")]
1327        walkthrough_file: Option<PathBuf>,
1328
1329        /// Render the existing walkthrough guide as a staged HUMAN terminal tour
1330        /// (Stage 1 load-bearing / Stage 2 mechanical), or markdown with
1331        /// `--format markdown`. Implies the brief; always exits 0.
1332        /// `--format json --walkthrough` emits the same agent-contract JSON as
1333        /// `--walkthrough-guide`.
1334        #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1335        walkthrough: bool,
1336
1337        /// Record one or more changed files as VIEWED in the local walkthrough
1338        /// viewed-state ledger (`.fallow/walkthrough-state.json`), then render the
1339        /// tour. Files already viewed (and still current) collapse into the
1340        /// Cleared panel. Repeatable. Stale marks (the tree moved) are ignored on
1341        /// render but never deleted. Only consulted on the `--walkthrough` path.
1342        #[arg(long, value_name = "PATH")]
1343        mark_viewed: Vec<PathBuf>,
1344
1345        /// Expand the Cleared panel in the human/markdown walkthrough tour: list
1346        /// each de-prioritized and already-viewed file instead of the collapsed
1347        /// one-line summary. Only consulted on the `--walkthrough` path.
1348        #[arg(long)]
1349        show_cleared: bool,
1350
1351        /// Expand the de-prioritized units in the review brief's weighted
1352        /// focus map ("show me what you de-prioritized"). The `deprioritized`
1353        /// escape-hatch list is ALWAYS present in `--format json` regardless; this
1354        /// flag only re-expands the collapse-by-default human focus render. Only
1355        /// consulted on the brief path.
1356        #[arg(long)]
1357        show_deprioritized: bool,
1358    },
1359
1360    /// Maintain reusable audit base-snapshot caches.
1361    AuditCache {
1362        #[command(subcommand)]
1363        subcommand: AuditCacheCli,
1364    },
1365
1366    /// Surface the consequential structural DECISIONS a change embeds (the apex
1367    /// of the review brief), each framed as a judgment question with the routed
1368    /// expert to ask.
1369    ///
1370    /// The product's decision surface: a ranked, capped (4 plus or minus 1),
1371    /// signal_id-anchored set of the SOLID-3 decisions (coupling/boundary,
1372    /// exports-aware public-API/contract, dependency). Runs the same changed-code
1373    /// analysis as `fallow review` but emits ONLY the decisions, separable and
1374    /// cheap. Every decision is suppressible with `// fallow-ignore`. Always
1375    /// exits 0 (advisory, never a gate). Use `--base` / `--changed-since` to pick
1376    /// the comparison point, exactly like `fallow audit`.
1377    DecisionSurface {
1378        /// Cap on the number of surfaced decisions (the working-memory limit).
1379        /// Default 4; clamped to the 3-5 band (4 plus or minus 1).
1380        #[arg(
1381            long,
1382            value_name = "N",
1383            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1384        )]
1385        max_decisions: usize,
1386    },
1387
1388    /// Show what fallow has done for you: how many issues it is surfacing, the
1389    /// trend since the last recorded run, and how many commits it contained at
1390    /// the pre-commit gate.
1391    ///
1392    /// Local-only and opt-in: enable per project with `fallow impact enable`, or
1393    /// turn it on everywhere with `fallow impact default on`, then let your
1394    /// `fallow audit` / pre-commit gate runs build history. History is stored in
1395    /// your user config dir (never written into the repo) and forced off in CI.
1396    /// Impact never uploads anything and never affects exit codes.
1397    Impact {
1398        #[command(subcommand)]
1399        subcommand: Option<ImpactCli>,
1400        /// Aggregate every tracked project into one cross-repo roll-up
1401        /// ("what has fallow done for me across all my repos"). Reads the
1402        /// user config dir; ignores `--root`. Cannot combine with a subcommand.
1403        #[arg(long)]
1404        all: bool,
1405        /// Row ordering for `--all` (default: most recently recorded first).
1406        #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1407        sort: ImpactSortCli,
1408        /// Cap the number of `--all` rows printed (grand totals still reflect
1409        /// every tracked project).
1410        #[arg(long)]
1411        limit: Option<usize>,
1412    },
1413
1414    /// Surface local security candidates for downstream agent verification (opt-in).
1415    ///
1416    /// Ships three complementary surfaces. (1) The graph-structural
1417    /// `client-server-leak` rule: a `"use client"` file that transitively imports
1418    /// a module reading a non-public env secret through `process.env` or
1419    /// `import.meta.env`. (2) The data-driven
1420    /// `tainted-sink` catalogue: syntactic sink sites matched against a CWE
1421    /// catalogue (`security_matchers.toml`) spanning categories such as
1422    /// dangerous-html, template-escape-bypass, command-injection, code-injection,
1423    /// dynamic-regex, redos-regex, resource-amplification, dynamic-module-load,
1424    /// sql-injection, ssrf, path-traversal, header-injection, open-redirect,
1425    /// cleartext-transport, electron-unsafe-webpreferences,
1426    /// world-writable-permission, insecure-temp-file,
1427    /// mysql-multiple-statements, mass-assignment, weak-crypto,
1428    /// deprecated-cipher, insecure-randomness,
1429    /// unsafe-buffer-alloc, unsafe-deserialization, prototype-pollution,
1430    /// zip-slip, nosql-injection, ssti, xxe, xpath-injection, and
1431    /// webview-injection. (3) `hardcoded-secret`,
1432    /// an include-required
1433    /// category for provider-prefix literals and high-entropy literals assigned
1434    /// to secret-shaped identifiers. It never runs from raw entropy alone. All
1435    /// findings are CANDIDATES for verification, NOT verified vulnerabilities.
1436    /// This command is the only
1437    /// surface for security findings; they never appear under bare `fallow` or
1438    /// the `audit` gate. Build-config and test files are excluded, and public
1439    /// env prefixes such as `NEXT_PUBLIC_` and `VITE_` are treated as public.
1440    /// Honors
1441    /// `--root`, `--format {human,json,sarif}`, `--changed-since`, `--file`, `--gate`, `--diff-file`,
1442    /// `--diff-stdin`, `--workspace`, `--changed-workspaces`, `--ci`,
1443    /// `--fail-on-issues`, `--sarif-file`, `--summary`, `--explain`, and `--surface`.
1444    Security {
1445        #[command(subcommand)]
1446        subcommand: Option<SecuritySubcommand>,
1447        /// Paid runtime-coverage sidecar input. Accepts a V8 directory, a
1448        /// single V8 JSON file, or an Istanbul coverage map JSON. When set,
1449        /// `fallow security` annotates tainted-sink candidates with production
1450        /// runtime state and uses that state as an additive ranking signal.
1451        #[arg(long, value_name = "PATH")]
1452        runtime_coverage: Option<PathBuf>,
1453        /// Threshold for hot-path classification, forwarded to the sidecar
1454        /// when `--runtime-coverage` is set.
1455        #[arg(long, default_value_t = 100)]
1456        min_invocations_hot: u64,
1457        /// Only report security candidates in or reachable from the specified files.
1458        /// The full project graph is still built, but output is scoped to matching
1459        /// finding anchors or trace hops. Accepts multiple values.
1460        #[arg(long, value_name = "PATH")]
1461        file: Vec<std::path::PathBuf>,
1462        /// Opt-in regression gate: fail (exit 8) only when the change introduces a
1463        /// NEW security-sink candidate in the changed lines, not on the whole
1464        /// candidate backlog. Requires a diff source: `--changed-since <ref>`,
1465        /// `--diff-file <path>`, or `--diff-stdin`. There is deliberately no `all`
1466        /// mode (gating on the full backlog is the anti-feature this gate avoids).
1467        #[arg(long, value_name = "MODE")]
1468        gate: Option<security::SecurityGateArg>,
1469        /// Include the agent-facing attack-surface inventory in JSON output.
1470        #[arg(long)]
1471        surface: bool,
1472    },
1473
1474    /// Render a saved `--format json` results file in another format without
1475    /// re-running analysis (analyze once, render annotations and the job
1476    /// summary from the same file). v1 renders the GitHub-native formats only:
1477    /// `--format github-annotations` or `--format github-summary`.
1478    Report {
1479        /// Path to a fallow JSON results file produced by `--format json`
1480        /// (dead-code, dupes, health, audit, security, or bare combined).
1481        #[arg(long, value_name = "PATH")]
1482        from: PathBuf,
1483    },
1484    /// Dump fallow's capability manifest (CLI commands and flags, issue types, MCP tools, framework plugins, env vars) as machine-readable JSON for agent introspection. Always JSON, regardless of --format
1485    Schema,
1486
1487    /// Print or vendor CI integration templates.
1488    ///
1489    /// Use `fallow ci-template gitlab` to print the GitLab CI template, or
1490    /// `fallow ci-template gitlab --vendor` to write the template plus the
1491    /// bash helper files that enable MR comments without downloading from
1492    /// raw.githubusercontent.com at pipeline runtime.
1493    CiTemplate {
1494        #[command(subcommand)]
1495        subcommand: CiTemplateCli,
1496    },
1497
1498    /// Migrate configuration from knip, jscpd, or stylelint to fallow
1499    Migrate {
1500        /// Generate `fallow.toml` instead of JSONC
1501        #[arg(long, conflicts_with = "jsonc")]
1502        toml: bool,
1503
1504        /// Write JSONC content to `.fallowrc.jsonc` instead of `.fallowrc.json`. The
1505        /// generated content is the same JSONC (with `//` comments) either way; the
1506        /// `.jsonc` extension lets editors auto-detect JSON-with-comments syntax
1507        /// highlighting and silences linters that flag comments in `.json`. Without
1508        /// `--jsonc` or `--toml`, fallow auto-mirrors the source extension: a
1509        /// `knip.jsonc` migration writes `.fallowrc.jsonc`, a `knip.json` migration
1510        /// writes `.fallowrc.json`.
1511        #[arg(long)]
1512        jsonc: bool,
1513
1514        /// Only preview the generated config without writing
1515        #[arg(long)]
1516        dry_run: bool,
1517
1518        /// Path to source config file (auto-detect if not specified)
1519        #[arg(long, value_name = "PATH")]
1520        from: Option<PathBuf>,
1521    },
1522
1523    /// Manage the license for continuous/cloud runtime monitoring.
1524    ///
1525    /// Verification is offline against an Ed25519 public key compiled into
1526    /// the binary. The license file lives at `~/.fallow/license.jwt` (or
1527    /// `$FALLOW_LICENSE_PATH`); `$FALLOW_LICENSE` env var takes precedence
1528    /// and is the recommended path for shared CI runners.
1529    License {
1530        #[command(subcommand)]
1531        subcommand: LicenseCli,
1532    },
1533
1534    /// Manage opt-in product telemetry.
1535    ///
1536    /// Telemetry is off by default. It never collects repository names, paths,
1537    /// package names, source code, config values, raw errors, or raw agent
1538    /// detection evidence. Use `fallow telemetry inspect --example` to see the
1539    /// documented payload shape, or prefix a real command with
1540    /// `FALLOW_TELEMETRY=inspect` to print the exact payload without sending.
1541    Telemetry {
1542        #[command(subcommand)]
1543        subcommand: TelemetryCli,
1544    },
1545
1546    /// Runtime coverage workflow.
1547    ///
1548    /// `setup` is the resumable single-entry-point first-run flow: license
1549    /// check → sidecar install → coverage recipe → analysis. Spec:
1550    /// `.internal/spec-runtime-coverage-phase-2.md` (private repo).
1551    Coverage {
1552        #[command(subcommand)]
1553        subcommand: CoverageCli,
1554    },
1555
1556    /// Install or remove a Claude Code PreToolUse hook that gates
1557    /// `git commit` / `git push` on `fallow audit`, so the agent cleans
1558    /// findings before the command runs.
1559    ///
1560    /// This is the legacy AGENT-level enforcement command. Prefer
1561    /// `fallow hooks install --target agent` for new setup. It writes into
1562    /// `.claude/settings.json` + `.claude/hooks/fallow-gate.sh` (and
1563    /// optionally an `AGENTS.md` managed block for Codex). For a
1564    /// shell-level Git pre-commit hook in `.git/hooks/`, see
1565    /// `fallow hooks install --target git` instead. Both targets can be used
1566    /// together: git hooks catch human commits, agent hooks catch agent
1567    /// commits.
1568    ///
1569    /// See `/integrations/claude-hooks` in the docs for the full recipe.
1570    SetupHooks {
1571        /// Target a specific agent surface (default: auto-detect).
1572        #[arg(long, value_enum)]
1573        agent: Option<setup_hooks::HookAgentArg>,
1574
1575        /// Print what would be written or removed without touching the filesystem.
1576        #[arg(long)]
1577        dry_run: bool,
1578
1579        /// Overwrite a user-edited hook script, invalid settings.json, or
1580        /// remove a user-edited script during uninstall.
1581        #[arg(long)]
1582        force: bool,
1583
1584        /// Write to the user's home directory instead of the project root.
1585        #[arg(long)]
1586        user: bool,
1587
1588        /// Append `.claude/` to the project's `.gitignore`.
1589        #[arg(long)]
1590        gitignore_claude: bool,
1591
1592        /// Remove the fallow-gate handler, hook script, and AGENTS.md
1593        /// managed block instead of installing them. Idempotent: reports
1594        /// "unchanged" when nothing to remove.
1595        #[arg(long)]
1596        uninstall: bool,
1597    },
1598}
1599
1600#[derive(Subcommand)]
1601enum SecuritySubcommand {
1602    /// Render verifier-retained survivor candidates from fallow output plus verifier verdicts.
1603    Survivors {
1604        /// Raw `fallow security --format json` candidate output.
1605        #[arg(long, value_name = "PATH")]
1606        candidates: PathBuf,
1607        /// Verifier verdict JSON file.
1608        #[arg(long, value_name = "PATH")]
1609        verdicts: PathBuf,
1610        /// Fail when any candidate has no matching verdict.
1611        #[arg(long)]
1612        require_verdict_for_each_candidate: bool,
1613    },
1614    /// Group unresolved security callees into actionable blind-spot output.
1615    #[command(name = "blind-spots")]
1616    BlindSpots {
1617        /// Scope diagnostics to selected files.
1618        #[arg(long, value_name = "PATH")]
1619        file: Vec<PathBuf>,
1620    },
1621}
1622
1623#[derive(clap::Subcommand)]
1624enum AuditCacheCli {
1625    /// Remove reusable audit caches owned by an explicit project root.
1626    Remove {
1627        /// Print what would be removed without touching the filesystem.
1628        #[arg(long)]
1629        dry_run: bool,
1630
1631        /// Confirm removal in non-interactive environments.
1632        #[arg(long, alias = "force")]
1633        yes: bool,
1634    },
1635}
1636
1637#[derive(clap::Subcommand)]
1638enum LicenseCli {
1639    /// Activate a license JWT.
1640    ///
1641    /// JWT input precedence: positional arg > `--from-file` > stdin (`-`).
1642    /// All paths normalize whitespace before crypto verification.
1643    Activate {
1644        /// JWT as a positional argument.
1645        #[arg(value_name = "JWT")]
1646        jwt: Option<String>,
1647
1648        /// Path to a file containing the JWT.
1649        #[arg(long, value_name = "PATH")]
1650        from_file: Option<PathBuf>,
1651
1652        /// Read JWT from stdin.
1653        #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1654        stdin: bool,
1655
1656        /// Start a 30-day email-gated trial in one step.
1657        ///
1658        /// The trial endpoint is rate-limited to 5 requests per hour per IP.
1659        /// In CI or behind a shared NAT, start the trial from a developer
1660        /// machine and set FALLOW_LICENSE (or FALLOW_LICENSE_PATH) on the
1661        /// runner instead of re-running `activate --trial` per job.
1662        #[arg(long, requires = "email")]
1663        trial: bool,
1664
1665        /// Email address for the trial flow.
1666        #[arg(long, value_name = "ADDR")]
1667        email: Option<String>,
1668    },
1669    /// Show the active license tier, seats, features, and days remaining.
1670    Status,
1671    /// Fetch a fresh JWT from `api.fallow.cloud` (network-only).
1672    Refresh,
1673    /// Remove the local license file.
1674    Deactivate,
1675}
1676
1677#[derive(Clone, Copy, clap::Subcommand)]
1678enum TelemetryCli {
1679    /// Show effective telemetry state, precedence, and controls.
1680    Status,
1681    /// Enable opt-in telemetry in the user-level fallow config.
1682    Enable,
1683    /// Disable telemetry in the user-level fallow config.
1684    Disable,
1685    /// Explain inspect mode or print example payloads.
1686    Inspect {
1687        /// Print documented example payloads and field purposes.
1688        #[arg(long)]
1689        example: bool,
1690    },
1691}
1692
1693#[derive(clap::Subcommand)]
1694enum CiTemplateCli {
1695    /// Print or vendor the GitLab CI template and MR integration helpers.
1696    Gitlab {
1697        /// Write ci/ and action/ helper files under DIR instead of printing the template.
1698        ///
1699        /// Passing --vendor without a DIR writes into the current directory.
1700        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1701        vendor: Option<PathBuf>,
1702
1703        /// Overwrite existing files that differ from the bundled template.
1704        #[arg(long)]
1705        force: bool,
1706    },
1707}
1708
1709#[derive(clap::Subcommand)]
1710enum CoverageCli {
1711    /// Resumable first-run setup: license + sidecar + recipe + analysis.
1712    Setup {
1713        /// Accept all prompts automatically.
1714        #[arg(short = 'y', long)]
1715        yes: bool,
1716
1717        /// Print instructions instead of prompting.
1718        #[arg(long)]
1719        non_interactive: bool,
1720
1721        /// Emit deterministic setup instructions as JSON. Implies --non-interactive.
1722        #[arg(long)]
1723        json: bool,
1724    },
1725    /// Analyze runtime coverage from a local artifact or explicit cloud source.
1726    ///
1727    /// Cloud mode is opt-in only. `FALLOW_API_KEY` by itself never selects
1728    /// cloud mode; pass `--cloud` / `--runtime-coverage-cloud`, or set
1729    /// `FALLOW_RUNTIME_COVERAGE_SOURCE=cloud`.
1730    Analyze {
1731        /// File or directory containing local runtime coverage input.
1732        #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1733        runtime_coverage: Option<PathBuf>,
1734
1735        /// Fetch latest runtime facts from fallow cloud for the selected repo.
1736        #[arg(long, visible_alias = "runtime-coverage-cloud")]
1737        cloud: bool,
1738
1739        /// Fallow cloud API key. Precedence: this flag > $FALLOW_API_KEY.
1740        #[arg(long, value_name = "KEY")]
1741        api_key: Option<String>,
1742
1743        /// Override the fallow cloud base URL.
1744        #[arg(long, value_name = "URL")]
1745        api_endpoint: Option<String>,
1746
1747        /// Repository identifier, for example `owner/repo`.
1748        ///
1749        /// Defaults to $FALLOW_REPO, then the parsed origin URL from
1750        /// `git remote get-url origin`. Slashes are percent-encoded as one
1751        /// URL segment when calling the cloud runtime-context endpoint.
1752        #[arg(long, value_name = "OWNER/REPO")]
1753        repo: Option<String>,
1754
1755        /// Optional monorepo/project disambiguator.
1756        #[arg(long, value_name = "ID")]
1757        project_id: Option<String>,
1758
1759        /// Runtime observation window to request from cloud (1..=90 days).
1760        #[arg(long, value_name = "DAYS", default_value_t = 30)]
1761        coverage_period: u16,
1762
1763        /// Optional runtime environment filter.
1764        #[arg(long, value_name = "ENV")]
1765        environment: Option<String>,
1766
1767        /// Optional commit SHA filter for cloud runtime facts.
1768        #[arg(long, value_name = "SHA")]
1769        commit_sha: Option<String>,
1770
1771        /// Analyze production code only.
1772        #[arg(long)]
1773        production: bool,
1774
1775        /// Threshold for hot-path classification.
1776        #[arg(long, default_value_t = 100)]
1777        min_invocations_hot: u64,
1778
1779        /// Minimum total trace volume before high-confidence verdicts.
1780        #[arg(long, value_name = "N")]
1781        min_observation_volume: Option<u32>,
1782
1783        /// Fraction of total trace count below which an invoked function is low traffic.
1784        #[arg(long, value_name = "RATIO")]
1785        low_traffic_threshold: Option<f64>,
1786
1787        /// Show only the top N runtime findings and hot paths.
1788        #[arg(long)]
1789        top: Option<usize>,
1790
1791        /// Show the first-class blast-radius section in human output.
1792        #[arg(long)]
1793        blast_radius: bool,
1794
1795        /// Show the first-class importance section in human output.
1796        #[arg(long)]
1797        importance: bool,
1798    },
1799    /// Upload a static function inventory to fallow cloud (Production
1800    /// Coverage, paid). Unlocks the `untracked` filter on the dashboard by
1801    /// pairing runtime coverage data with the AST view of "every function
1802    /// that exists". See <https://docs.fallow.tools/analysis/runtime-coverage>.
1803    ///
1804    /// This command makes network calls to fallow cloud. `fallow dead-code`
1805    /// stays offline.
1806    ///
1807    /// Exit codes: 0 ok · 7 network · 10 validation · 11 payload too large
1808    /// · 12 auth rejected · 13 server error.
1809    UploadInventory {
1810        /// Fallow cloud API key (bearer token).
1811        ///
1812        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
1813        /// <https://fallow.cloud/settings#api-keys>.
1814        ///
1815        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
1816        /// secret on the command line may be visible to other processes via
1817        /// `ps` and can leak into shell history or process audit logs.
1818        #[arg(long, value_name = "KEY")]
1819        api_key: Option<String>,
1820
1821        /// Override the fallow cloud base URL.
1822        ///
1823        /// Useful for staging and on-premise deployments. Also respects
1824        /// $FALLOW_API_URL when this flag is not set.
1825        #[arg(long, value_name = "URL")]
1826        api_endpoint: Option<String>,
1827
1828        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
1829        ///
1830        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
1831        /// parsed origin URL from `git remote get-url origin`.
1832        #[arg(long, value_name = "PROJECT_ID")]
1833        project_id: Option<String>,
1834
1835        /// Explicit git SHA for this inventory.
1836        ///
1837        /// Default: `git rev-parse HEAD`. The inventory is keyed on this
1838        /// value; the cloud back-fills hourly buckets with a matching SHA.
1839        #[arg(long, value_name = "SHA")]
1840        git_sha: Option<String>,
1841
1842        /// Proceed even when the working tree has uncommitted changes.
1843        ///
1844        /// Warning: the inventory is generated from the working copy, so it
1845        /// may not match the uploaded git SHA. Commit or stash first if you
1846        /// want a SHA-exact upload.
1847        #[arg(long)]
1848        allow_dirty: bool,
1849
1850        /// Additional glob patterns to exclude from the walk.
1851        ///
1852        /// Applied after the existing fallow ignore rules. Repeatable.
1853        #[arg(long, value_name = "GLOB", num_args = 0..)]
1854        exclude_paths: Vec<String>,
1855
1856        /// Prefix prepended to every emitted filePath so the static
1857        /// inventory joins with the runtime beacon for your deployment.
1858        /// Required for containerized deployments where the deployed
1859        /// WORKDIR rebases paths at runtime. Default: none (paths emit
1860        /// repo-relative, matching local runs and non-container CI).
1861        ///
1862        /// Common values: `/app` (typical Dockerfile), `/workspace`
1863        /// (Buildpacks / Cloud Run), `/usr/src/app` (older Node images),
1864        /// `/var/task` (Lambda), `/home/runner/work/<repo>/<repo>`
1865        /// (GitHub Actions default checkout).
1866        ///
1867        /// Must start with `/` and use POSIX separators.
1868        #[arg(long, value_name = "PREFIX")]
1869        path_prefix: Option<String>,
1870
1871        /// Print what would be uploaded and exit. No network call.
1872        #[arg(long)]
1873        dry_run: bool,
1874
1875        /// Also upload importer edges (which files import each function) so the
1876        /// cloud can show change-time blast radius. Opt-in: this builds the
1877        /// import graph by running the full static analysis, whereas the default
1878        /// upload is a fast per-file walk. The graph is cached, so a CI step that
1879        /// already ran analysis pays little extra.
1880        #[arg(long)]
1881        with_callers: bool,
1882
1883        /// Treat transient upload failures as warnings instead of errors
1884        /// (exit 0). Validation and auth errors still fail hard; this only
1885        /// downgrades transport and server errors.
1886        #[arg(long)]
1887        ignore_upload_errors: bool,
1888    },
1889    /// Upload JavaScript source maps to fallow cloud for bundled runtime coverage.
1890    ///
1891    /// Scans a build output directory for `.map` files and uploads them under
1892    /// the selected repo + git SHA. The production beacon reports bundled
1893    /// paths; the cloud resolver uses these maps to remap runtime coverage back
1894    /// to original source files.
1895    ///
1896    /// Each upload also carries the map's path relative to the repo root, so the
1897    /// source-evidence viewer can resolve a monorepo sub-package map's relative
1898    /// `sources[]` (e.g. `../../src/X`) to the package-prefixed source path
1899    /// (e.g. `dashboard/src/X`). Run from the repo root so this prefix is
1900    /// correct.
1901    UploadSourceMaps {
1902        /// Directory to scan recursively for source maps.
1903        #[arg(long, value_name = "PATH", default_value = "dist")]
1904        dir: PathBuf,
1905
1906        /// Glob pattern, relative to --dir, selecting maps to upload.
1907        #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1908        include: String,
1909
1910        /// Glob pattern, relative to --dir, selecting files to skip.
1911        ///
1912        /// Repeatable. Defaults to `**/node_modules/**`.
1913        #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
1914        exclude: Vec<String>,
1915
1916        /// Repo name used in the API path.
1917        ///
1918        /// Defaults to package.json repository.url, then `git remote get-url origin`.
1919        #[arg(long, value_name = "NAME")]
1920        repo: Option<String>,
1921
1922        /// Commit SHA to key uploads under.
1923        ///
1924        /// Defaults to $GITHUB_SHA, $CI_COMMIT_SHA, $COMMIT_SHA, then
1925        /// `git rev-parse HEAD`.
1926        #[arg(long, value_name = "SHA")]
1927        git_sha: Option<String>,
1928
1929        /// Override the fallow cloud base URL.
1930        #[arg(long, value_name = "URL")]
1931        endpoint: Option<String>,
1932
1933        /// Send only the basename as fileName by default.
1934        ///
1935        /// Use `--strip-path=false` when your runtime coverage reports bundle
1936        /// paths relative to the build directory, such as `assets/app.js`.
1937        #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
1938        strip_path: bool,
1939
1940        /// Print what would be uploaded and exit. No network call.
1941        #[arg(long)]
1942        dry_run: bool,
1943
1944        /// Parallel upload fanout.
1945        #[arg(long, value_name = "N", default_value_t = 4)]
1946        concurrency: usize,
1947
1948        /// Stop on first upload error.
1949        #[arg(long)]
1950        fail_fast: bool,
1951    },
1952    /// Upload static dead-code findings to fallow cloud for the source-evidence viewer.
1953    ///
1954    /// Runs fallow's static analysis and uploads the `unused_export` and
1955    /// `dead_file` verdicts under the selected repo + git SHA. The cloud
1956    /// overlays them on the source view alongside the runtime coverage overlay.
1957    /// Findings are replace-by-SHA: each run sends the complete set for the SHA.
1958    UploadStaticFindings {
1959        /// Fallow cloud API key (bearer token).
1960        ///
1961        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
1962        /// <https://fallow.cloud/settings#api-keys>. This must be a live API
1963        /// key, not a publishable ingest key.
1964        ///
1965        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
1966        /// secret on the command line may be visible to other processes via
1967        /// `ps` and can leak into shell history or process audit logs.
1968        #[arg(long, value_name = "KEY")]
1969        api_key: Option<String>,
1970
1971        /// Override the fallow cloud base URL.
1972        ///
1973        /// Useful for staging and on-premise deployments. Also respects
1974        /// $FALLOW_API_URL when this flag is not set.
1975        #[arg(long, value_name = "URL")]
1976        api_endpoint: Option<String>,
1977
1978        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
1979        ///
1980        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
1981        /// parsed origin URL from `git remote get-url origin`.
1982        #[arg(long, value_name = "PROJECT_ID")]
1983        project_id: Option<String>,
1984
1985        /// Explicit git SHA for these findings.
1986        ///
1987        /// Default: `git rev-parse HEAD`. Findings are keyed on this value and
1988        /// fully replace any prior set uploaded for the same SHA.
1989        #[arg(long, value_name = "SHA")]
1990        git_sha: Option<String>,
1991
1992        /// Proceed even when the working tree has uncommitted changes.
1993        ///
1994        /// Warning: findings are generated from the working copy, so they may
1995        /// not match the uploaded git SHA. Commit or stash first if you want a
1996        /// SHA-exact upload.
1997        #[arg(long)]
1998        allow_dirty: bool,
1999
2000        /// Print what would be uploaded and exit. No network call.
2001        #[arg(long)]
2002        dry_run: bool,
2003
2004        /// Treat transient upload failures as warnings instead of errors
2005        /// (exit 0). Validation and auth errors still fail hard; this only
2006        /// downgrades transport and server errors.
2007        #[arg(long)]
2008        ignore_upload_errors: bool,
2009    },
2010}
2011
2012#[derive(Subcommand)]
2013enum CiCli {
2014    /// Compute the provider action for a rendered sticky PR summary comment.
2015    PlanPrComment {
2016        /// Path to the rendered PR comment Markdown body.
2017        #[arg(long)]
2018        body: PathBuf,
2019
2020        /// Sticky marker id used in the rendered body.
2021        #[arg(long)]
2022        marker_id: String,
2023
2024        /// Treat the rendered body as a clean no-findings result.
2025        #[arg(long)]
2026        clean: bool,
2027
2028        /// Existing provider comment id, when a matching sticky comment exists.
2029        #[arg(long)]
2030        existing_comment_id: Option<String>,
2031
2032        /// Path to the existing provider comment body. Enables unchanged-skip planning.
2033        #[arg(long)]
2034        existing_body: Option<PathBuf>,
2035    },
2036
2037    /// Post, update, or skip a rendered sticky PR summary comment.
2038    PostPrComment {
2039        /// Provider whose PR comment is being posted.
2040        #[arg(long, value_enum)]
2041        provider: CiProviderArg,
2042
2043        /// Pull request number (GitHub).
2044        #[arg(long)]
2045        pr: Option<String>,
2046
2047        /// Merge request IID (GitLab).
2048        #[arg(long)]
2049        mr: Option<String>,
2050
2051        /// Path to the rendered PR comment Markdown body.
2052        #[arg(long)]
2053        body: PathBuf,
2054
2055        /// Path to the typed PR comment envelope JSON, when available.
2056        #[arg(long)]
2057        envelope: Option<PathBuf>,
2058
2059        /// Sticky marker id used in the rendered body.
2060        #[arg(long)]
2061        marker_id: String,
2062
2063        /// Treat the rendered body as a clean no-findings result.
2064        #[arg(long)]
2065        clean: bool,
2066
2067        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2068        #[arg(long)]
2069        repo: Option<String>,
2070
2071        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2072        #[arg(long = "project-id")]
2073        project_id: Option<String>,
2074
2075        /// Provider API base URL. Defaults to github.com.
2076        #[arg(long = "api-url")]
2077        api_url: Option<String>,
2078
2079        /// Compute the post plan without creating or updating the provider comment.
2080        #[arg(long)]
2081        dry_run: bool,
2082    },
2083
2084    /// Post a rendered review envelope as a provider review or summary comment.
2085    PostReview {
2086        /// Provider whose review envelope is being posted.
2087        #[arg(long, value_enum)]
2088        provider: CiProviderArg,
2089
2090        /// Pull request number (GitHub).
2091        #[arg(long)]
2092        pr: Option<String>,
2093
2094        /// Merge request IID (GitLab).
2095        #[arg(long)]
2096        mr: Option<String>,
2097
2098        /// Path to a review-github or review-gitlab JSON envelope.
2099        #[arg(long)]
2100        envelope: PathBuf,
2101
2102        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2103        #[arg(long)]
2104        repo: Option<String>,
2105
2106        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2107        #[arg(long = "project-id")]
2108        project_id: Option<String>,
2109
2110        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
2111        #[arg(long = "api-url")]
2112        api_url: Option<String>,
2113
2114        /// Compute the post plan without creating provider comments.
2115        #[arg(long)]
2116        dry_run: bool,
2117    },
2118
2119    /// Post a GitHub Check Run from a typed PR decision surface.
2120    PostCheckRun {
2121        /// Provider whose check run is being posted. Only GitHub is supported.
2122        #[arg(long, value_enum)]
2123        provider: CiProviderArg,
2124
2125        /// Path to a fallow-pr-decision JSON sidecar.
2126        #[arg(long)]
2127        decision: PathBuf,
2128
2129        /// GitHub repository in owner/name form.
2130        #[arg(long)]
2131        repo: String,
2132
2133        /// Head SHA the check run should attach to.
2134        #[arg(long = "head-sha")]
2135        head_sha: String,
2136
2137        /// Provider API base URL. Defaults to github.com.
2138        #[arg(long = "api-url")]
2139        api_url: Option<String>,
2140
2141        /// Post one check run per decision gate instead of one aggregate check.
2142        #[arg(long = "split-gates")]
2143        split_gates: bool,
2144
2145        /// Print the check run payload without posting it.
2146        #[arg(long)]
2147        dry_run: bool,
2148    },
2149
2150    /// Validate a rendered review envelope and compute a stable reconcile plan.
2151    ReconcileReview {
2152        /// Provider whose review envelope is being reconciled.
2153        #[arg(long, value_enum)]
2154        provider: CiProviderArg,
2155
2156        /// Pull request number (GitHub).
2157        #[arg(long)]
2158        pr: Option<String>,
2159
2160        /// Merge request IID (GitLab).
2161        #[arg(long)]
2162        mr: Option<String>,
2163
2164        /// Path to a review-github or review-gitlab JSON envelope.
2165        #[arg(long)]
2166        envelope: PathBuf,
2167
2168        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2169        #[arg(long)]
2170        repo: Option<String>,
2171
2172        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2173        #[arg(long = "project-id")]
2174        project_id: Option<String>,
2175
2176        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
2177        #[arg(long = "api-url")]
2178        api_url: Option<String>,
2179
2180        /// Compute the reconcile plan without posting resolution notes or resolving threads.
2181        #[arg(long)]
2182        dry_run: bool,
2183    },
2184}
2185
2186#[derive(Subcommand)]
2187enum RulePackCli {
2188    /// Scaffold a new rule pack file and wire it into the config
2189    Init {
2190        /// Pack name (default: the template name, or "team-policy")
2191        name: Option<String>,
2192
2193        /// Template: starter, ai-safe-repo, side-effect-free-domain, clean-architecture, next-app-router
2194        #[arg(long, default_value = "starter")]
2195        template: String,
2196
2197        /// Directory for the pack file, relative to the project root
2198        #[arg(long, default_value = "rule-packs")]
2199        dir: String,
2200
2201        /// Only write the pack file; do not modify the config
2202        #[arg(long)]
2203        no_config: bool,
2204    },
2205
2206    /// List configured rule packs and their rules
2207    List,
2208
2209    /// Evaluate a pack (or all configured packs) against this project and print matches
2210    Test {
2211        /// Path to a pack file to test in isolation (default: all configured packs)
2212        pack: Option<PathBuf>,
2213    },
2214
2215    /// Print the JSON Schema for rule pack files
2216    Schema,
2217}
2218
2219#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2220enum CiProviderArg {
2221    Github,
2222    Gitlab,
2223}
2224
2225/// Filter refactoring targets by effort level.
2226#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2227pub enum EffortFilter {
2228    Low,
2229    Medium,
2230    High,
2231}
2232
2233impl EffortFilter {
2234    /// Convert to the corresponding `EffortEstimate` for comparison.
2235    const fn to_estimate(self) -> fallow_output::EffortEstimate {
2236        match self {
2237            Self::Low => fallow_output::EffortEstimate::Low,
2238            Self::Medium => fallow_output::EffortEstimate::Medium,
2239            Self::High => fallow_output::EffortEstimate::High,
2240        }
2241    }
2242}
2243
2244/// CLI parser for the health severity gate.
2245#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2246pub enum HealthSeverityCli {
2247    Moderate,
2248    High,
2249    Critical,
2250}
2251
2252impl HealthSeverityCli {
2253    /// Convert to the typed health output severity.
2254    const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2255        match self {
2256            Self::Moderate => fallow_output::FindingSeverity::Moderate,
2257            Self::High => fallow_output::FindingSeverity::High,
2258            Self::Critical => fallow_output::FindingSeverity::Critical,
2259        }
2260    }
2261}
2262
2263/// Privacy mode for author emails emitted by `--ownership`.
2264///
2265/// CLI mirror of [`fallow_config::EmailMode`]. Kept as a separate enum so
2266/// the help text controls rendering and we don't leak config-internal
2267/// schema details into clap.
2268#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2269pub enum EmailModeArg {
2270    /// Show full email addresses as recorded in git history.
2271    Raw,
2272    /// Show local-part only (default). Unwraps GitHub-style noreply prefixes.
2273    Handle,
2274    /// Show stable non-cryptographic pseudonyms (`xxh3:<hex>`).
2275    Anonymized,
2276    /// Legacy spelling for anonymized output.
2277    #[value(hide = true)]
2278    Hash,
2279}
2280
2281impl EmailModeArg {
2282    /// Convert to the equivalent config-level mode.
2283    const fn to_config(self) -> fallow_config::EmailMode {
2284        match self {
2285            Self::Raw => fallow_config::EmailMode::Raw,
2286            Self::Handle => fallow_config::EmailMode::Handle,
2287            Self::Anonymized => fallow_config::EmailMode::Anonymized,
2288            Self::Hash => fallow_config::EmailMode::Hash,
2289        }
2290    }
2291}
2292
2293/// CLI mirror of [`fallow_config::AuditGate`].
2294#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2295pub enum AuditGateArg {
2296    /// Only findings introduced by the current changeset affect the verdict.
2297    NewOnly,
2298    /// All findings in changed files affect the verdict.
2299    All,
2300}
2301
2302impl From<AuditGateArg> for fallow_config::AuditGate {
2303    fn from(value: AuditGateArg) -> Self {
2304        match value {
2305            AuditGateArg::NewOnly => Self::NewOnly,
2306            AuditGateArg::All => Self::All,
2307        }
2308    }
2309}
2310
2311/// Parse `--min-occurrences` and reject values below 2. A single occurrence
2312/// is not a duplicate; silently clamping would diverge from the config-file
2313/// validator, which also rejects `< 2`.
2314fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2315    let value: usize = s
2316        .parse()
2317        .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2318    if value < 2 {
2319        return Err(format!(
2320            "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2321        ));
2322    }
2323    Ok(value)
2324}
2325
2326/// Resolve an audit baseline path using CLI > config precedence.
2327///
2328/// Both sources resolve relative paths against the project root. This keeps
2329/// behavior consistent in CI scripts where `--root $REPO_ROOT` differs from
2330/// the process CWD.
2331fn resolve_audit_baseline_path(
2332    root: &std::path::Path,
2333    cli: Option<&std::path::Path>,
2334    config: Option<&str>,
2335) -> Option<PathBuf> {
2336    let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2337        config.map(|p| {
2338            let path = PathBuf::from(p);
2339            if path_util::is_absolute_path_any_platform(&path) {
2340                path
2341            } else {
2342                root.join(path)
2343            }
2344        })
2345    })?;
2346    if path_util::is_absolute_path_any_platform(&path) {
2347        Some(path)
2348    } else {
2349        Some(root.join(path))
2350    }
2351}
2352
2353fn emit_known_failure(
2354    message: &str,
2355    exit_code: u8,
2356    output: fallow_config::OutputFormat,
2357    reason: telemetry::FailureReason,
2358) -> ExitCode {
2359    telemetry::note_failure_reason(reason);
2360    emit_error(message, exit_code, output)
2361}
2362
2363fn emit_known_failure_with_style(
2364    message: &str,
2365    exit_code: u8,
2366    output: fallow_config::OutputFormat,
2367    json_style: json_style::JsonStyle,
2368    reason: telemetry::FailureReason,
2369) -> ExitCode {
2370    telemetry::note_failure_reason(reason);
2371    error::emit_error_with_style(message, exit_code, output, json_style)
2372}
2373
2374fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2375    if cli.baseline.is_some() {
2376        Some("--baseline")
2377    } else if cli.save_baseline.is_some() {
2378        Some("--save-baseline")
2379    } else if cli.production {
2380        Some("--production")
2381    } else if cli.no_production {
2382        Some("--no-production")
2383    } else if cli.group_by.is_some() {
2384        Some("--group-by")
2385    } else if cli.performance {
2386        Some("--performance")
2387    } else if cli.explain_skipped {
2388        Some("--explain-skipped")
2389    } else if cli.fail_on_regression {
2390        Some("--fail-on-regression")
2391    } else if cli.regression_baseline.is_some() {
2392        Some("--regression-baseline")
2393    } else if cli.save_regression_baseline.is_some() {
2394        Some("--save-regression-baseline")
2395    } else if cli.dupes_mode.is_some() {
2396        Some("--dupes-mode")
2397    } else if cli.dupes_threshold.is_some() {
2398        Some("--dupes-threshold")
2399    } else if cli.dupes_min_tokens.is_some() {
2400        Some("--dupes-min-tokens")
2401    } else if cli.dupes_min_lines.is_some() {
2402        Some("--dupes-min-lines")
2403    } else if cli.dupes_min_occurrences.is_some() {
2404        Some("--dupes-min-occurrences")
2405    } else if cli.dupes_skip_local {
2406        Some("--dupes-skip-local")
2407    } else if cli.dupes_cross_language {
2408        Some("--dupes-cross-language")
2409    } else if cli.dupes_ignore_imports {
2410        Some("--dupes-ignore-imports")
2411    } else if cli.dupes_no_ignore_imports {
2412        Some("--dupes-no-ignore-imports")
2413    } else if cli.include_entry_exports {
2414        Some("--include-entry-exports")
2415    } else {
2416        None
2417    }
2418}
2419
2420struct DispatchContext<'a> {
2421    cli: &'a Cli,
2422    root: &'a std::path::Path,
2423    output: fallow_config::OutputFormat,
2424    quiet: bool,
2425    fail_on_issues: bool,
2426    json_style: json_style::JsonStyle,
2427    threads: usize,
2428    tolerance: regression::Tolerance,
2429    save_regression_file: Option<&'a std::path::PathBuf>,
2430    save_to_config: bool,
2431}
2432
2433impl DispatchContext<'_> {
2434    fn production_modes(
2435        &self,
2436        dead_code: bool,
2437        health: bool,
2438        dupes: bool,
2439    ) -> Result<ProductionModes, ExitCode> {
2440        resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2441    }
2442
2443    fn production_for(
2444        &self,
2445        analysis: fallow_config::ProductionAnalysis,
2446    ) -> Result<bool, ExitCode> {
2447        self.production_modes(false, false, false)
2448            .map(|modes| modes.for_analysis(analysis))
2449    }
2450
2451    fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2452        regression::RegressionOpts {
2453            fail_on_regression: self.cli.fail_on_regression,
2454            tolerance: self.tolerance,
2455            regression_baseline_file: self.cli.regression_baseline.as_deref(),
2456            save_target: if let Some(path) = self.save_regression_file {
2457                regression::SaveRegressionTarget::File(path)
2458            } else if self.save_to_config {
2459                regression::SaveRegressionTarget::Config
2460            } else {
2461                regression::SaveRegressionTarget::None
2462            },
2463            scoped,
2464            quiet: self.quiet,
2465            output: self.output,
2466        }
2467    }
2468}
2469
2470/// Test-only helper invoked when `FALLOW_TEST_SIGNAL_HELPER=1` is set.
2471/// Spawns `sleep 30` via the `ScopedChild` registry so the child is
2472/// tracked by the signal handler, prints the child PID to stdout, then
2473/// busy-waits so a SIGINT/SIGTERM delivered to the parent fires the
2474/// signal handler (which kills the child and exits 128+signum).
2475///
2476/// When `FALLOW_TEST_SIGNAL_HELPER_GRACEFUL=1` is also set, graceful
2477/// mode is activated BEFORE spawning the child. In graceful mode the
2478/// signal handler kills the child (proving drain runs unconditionally)
2479/// but does NOT call `std::process::exit`, so the helper itself sees
2480/// `wait_with_output` return and exits 0. This is the path the
2481/// integration test asserts: graceful drain + clean exit. Lives in
2482/// `main.rs` (not tests/) because clap is already parsed below and we
2483/// need to intercept before that.
2484#[cfg(unix)]
2485fn signal_test_helper() -> ExitCode {
2486    use std::io::Write as _;
2487    use std::process::Command;
2488
2489    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2490        signal::set_graceful_mode();
2491    }
2492
2493    let mut command = Command::new("sleep");
2494    command.arg("30");
2495    let child = match signal::ScopedChild::spawn(&mut command) {
2496        Ok(c) => c,
2497        Err(err) => {
2498            let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2499            return ExitCode::from(2);
2500        }
2501    };
2502    let pid = child.id();
2503    let stdout = std::io::stdout();
2504    let mut lock = stdout.lock();
2505    let _ = writeln!(lock, "{pid}");
2506    let _ = lock.flush();
2507    drop(lock);
2508    let _ = child.wait_with_output();
2509    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2510        return ExitCode::SUCCESS;
2511    }
2512    std::thread::sleep(std::time::Duration::from_secs(5));
2513    ExitCode::SUCCESS
2514}
2515
2516#[cfg(not(unix))]
2517fn signal_test_helper() -> ExitCode {
2518    ExitCode::from(2)
2519}
2520
2521fn install_spawn_hooks() {
2522    fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2523    fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2524}
2525
2526fn install_signal_handlers() {
2527    if let Err(err) = signal::install_handlers() {
2528        use std::io::Write as _;
2529        let stderr = std::io::stderr();
2530        let mut lock = stderr.lock();
2531        let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2532    }
2533}
2534
2535/// Open `path` (creating parent dirs, truncating) and redirect report output
2536/// there via the ambient sink, forcing color off so the file carries no ANSI
2537/// codes even when attached to a TTY. Returns the error exit code if the file
2538/// cannot be created. Backs `--output-file`.
2539fn redirect_report_to_file(
2540    path: &std::path::Path,
2541    output: fallow_config::OutputFormat,
2542) -> Result<(), ExitCode> {
2543    if let Some(parent) = path.parent()
2544        && !parent.as_os_str().is_empty()
2545        && let Err(e) = std::fs::create_dir_all(parent)
2546    {
2547        return Err(emit_error(
2548            &format!(
2549                "failed to create {} for --output-file: {e}",
2550                parent.display()
2551            ),
2552            2,
2553            output,
2554        ));
2555    }
2556    match std::fs::File::create(path) {
2557        Ok(file) => {
2558            report::sink::set_file_sink(file);
2559            colored::control::set_override(false);
2560            Ok(())
2561        }
2562        Err(e) => Err(emit_error(
2563            &format!("failed to open {} for --output-file: {e}", path.display()),
2564            2,
2565            output,
2566        )),
2567    }
2568}
2569
2570/// Flush the report file after rendering and print the stderr confirmation
2571/// (suppressed by `--quiet`). Returns the error exit code on a write failure.
2572fn finalize_report_file(
2573    path: &std::path::Path,
2574    quiet: bool,
2575    output: fallow_config::OutputFormat,
2576) -> Result<(), ExitCode> {
2577    if let Err(e) = report::sink::flush() {
2578        return Err(emit_error(
2579            &format!("failed to write {}: {e}", path.display()),
2580            2,
2581            output,
2582        ));
2583    }
2584    // Suppress the confirmation when nothing was rendered to the file (a command
2585    // that errored before producing output sends its error to stdout, not the
2586    // file), so we never claim "Report written" over an empty file.
2587    if !quiet && report::sink::wrote() {
2588        eprintln!("Report written to {}", path.display());
2589    }
2590    Ok(())
2591}
2592
2593/// Run the full fallow CLI: parse argv, dispatch the selected command, and
2594/// return the process exit code. This is the crate's single entry point; the
2595/// `fallow` binary and the multicall `fallow-multicall` binary both delegate
2596/// here so there is exactly one clap tree and one dispatch path.
2597pub fn run() -> ExitCode {
2598    install_signal_handlers();
2599    install_spawn_hooks();
2600
2601    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2602        return signal_test_helper();
2603    }
2604
2605    let (mut cli, fmt) = match parse_cli_args() {
2606        Ok(parsed) => parsed,
2607        Err(code) => return code,
2608    };
2609    if cli.pretty && !fmt.payload_is_json {
2610        eprintln!(
2611            "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2612        );
2613        return ExitCode::from(2);
2614    }
2615
2616    if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2617        return code;
2618    }
2619
2620    if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2621        return code;
2622    }
2623    let telemetry_run = start_telemetry_run(&cli, &fmt);
2624
2625    let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2626        Ok(v) => v,
2627        Err(code) => {
2628            return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2629        }
2630    };
2631
2632    let FormatConfig {
2633        output,
2634        payload_is_json: _,
2635        quiet,
2636        fail_on_issues,
2637        json_style,
2638    } = fmt;
2639
2640    let tolerance =
2641        match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2642            Ok(tolerance) => tolerance,
2643            Err(code) => return code,
2644        };
2645
2646    let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2647
2648    let command = cli.command.take();
2649    let dispatch = DispatchContext {
2650        cli: &cli,
2651        root: &root,
2652        output,
2653        quiet,
2654        fail_on_issues,
2655        json_style,
2656        threads,
2657        tolerance,
2658        save_regression_file: save_regression_file.as_ref(),
2659        save_to_config,
2660    };
2661    let exit_code = match dispatch_and_finalize(&dispatch, command) {
2662        Ok(code) => code,
2663        Err(code) => return code,
2664    };
2665    record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2666}
2667
2668/// Redirect the rendered report to `--output-file` (ambient sink), dispatch the
2669/// command, then flush+close the report file. Returns the dispatch exit code, or
2670/// `Err` carrying a redirect/finalize failure code for `main` to return directly.
2671fn dispatch_and_finalize(
2672    dispatch: &DispatchContext<'_>,
2673    command: Option<Command>,
2674) -> Result<ExitCode, ExitCode> {
2675    let cli = dispatch.cli;
2676    let output = dispatch.output;
2677    let quiet = dispatch.quiet;
2678
2679    // Set up the report-file sink before dispatch so rendering lands in the file;
2680    // progress and the confirmation stay on stderr.
2681    if let Some(path) = cli.output_file.as_deref()
2682        && let Err(code) = redirect_report_to_file(path, output)
2683    {
2684        return Err(code);
2685    }
2686
2687    let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2688        emit_error(bare_coverage_subcommand_error_message(), 2, output)
2689    } else {
2690        match command {
2691            None => dispatch_bare_command(dispatch),
2692            Some(cmd) => dispatch_subcommand(cmd, dispatch),
2693        }
2694    };
2695
2696    if let Some(path) = cli.output_file.as_deref()
2697        && let Err(code) = finalize_report_file(path, quiet, output)
2698    {
2699        return Err(code);
2700    }
2701    Ok(exit_code)
2702}
2703
2704fn run_telemetry_command_if_requested(
2705    cli: &mut Cli,
2706    output: fallow_config::OutputFormat,
2707    json_style: json_style::JsonStyle,
2708) -> Option<ExitCode> {
2709    if matches!(cli.command, Some(Command::Telemetry { .. }))
2710        && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2711    {
2712        return Some(telemetry::run(
2713            map_telemetry_subcommand(subcommand),
2714            output,
2715            json_style,
2716        ));
2717    }
2718    None
2719}
2720
2721fn run_schema_command_if_requested(
2722    cli: &Cli,
2723    json_style: json_style::JsonStyle,
2724) -> Option<ExitCode> {
2725    match cli.command {
2726        Some(Command::Schema) => Some(schema::run_schema(json_style)),
2727        Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2728        Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2729        Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2730        _ => None,
2731    }
2732}
2733
2734fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2735    let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2736        opt.as_ref()
2737            .filter(|path| !path.is_empty())
2738            .map(std::path::PathBuf::from)
2739    });
2740    let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2741    (save_file, save_to_config)
2742}
2743
2744fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2745    let cli = dispatch.cli;
2746    let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2747    let production = match dispatch.production_modes(
2748        cli.production_dead_code,
2749        cli.production_health,
2750        cli.production_dupes,
2751    ) {
2752        Ok(production) => production,
2753        Err(code) => return code,
2754    };
2755    let coverage_inputs = match resolve_health_coverage_inputs(
2756        dispatch,
2757        cli.coverage.as_deref(),
2758        cli.coverage_root.as_deref(),
2759    ) {
2760        Ok(inputs) => inputs,
2761        Err(code) => return code,
2762    };
2763    run_bare_combined(
2764        dispatch,
2765        production,
2766        &coverage_inputs,
2767        BareAnalyses {
2768            run_check,
2769            run_dupes,
2770            run_health,
2771        },
2772    )
2773}
2774
2775/// Which analyses the bare `fallow` run executes (resolved from `--only`/`--skip`).
2776#[derive(Clone, Copy)]
2777struct BareAnalyses {
2778    run_check: bool,
2779    run_dupes: bool,
2780    run_health: bool,
2781}
2782
2783/// Build `CombinedOptions` for a bare `fallow` invocation and run the combined
2784/// pipeline.
2785fn run_bare_combined(
2786    dispatch: &DispatchContext<'_>,
2787    production: ProductionModes,
2788    coverage_inputs: &ResolvedHealthCoverageInputs,
2789    analyses: BareAnalyses,
2790) -> ExitCode {
2791    let cli = dispatch.cli;
2792    let (output, quiet, fail_on_issues) =
2793        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
2794    combined::run_combined(&combined::CombinedOptions {
2795        root: dispatch.root,
2796        config_path: &cli.config,
2797        output,
2798        json_style: dispatch.json_style,
2799        no_cache: cli.no_cache,
2800        threads: dispatch.threads,
2801        quiet,
2802        allow_remote_extends: cli.allow_remote_extends,
2803        fail_on_issues,
2804        sarif_file: cli.sarif_file.as_deref(),
2805        changed_since: cli.changed_since.as_deref(),
2806        churn_file: cli.churn_file.as_deref(),
2807        baseline: cli.baseline.as_deref(),
2808        save_baseline: cli.save_baseline.as_deref(),
2809        production: cli.production,
2810        production_dead_code: Some(production.dead_code),
2811        production_health: Some(production.health),
2812        production_dupes: Some(production.dupes),
2813        workspace: cli.workspace.as_deref(),
2814        changed_workspaces: cli.changed_workspaces.as_deref(),
2815        group_by: cli.group_by,
2816        explain: cli.explain,
2817        explain_skipped: cli.explain_skipped,
2818        performance: cli.performance,
2819        summary: cli.summary,
2820        run_check: analyses.run_check,
2821        run_dupes: analyses.run_dupes,
2822        run_health: analyses.run_health,
2823        dupes_mode: cli.dupes_mode,
2824        dupes_threshold: cli.dupes_threshold,
2825        dupes_min_tokens: cli.dupes_min_tokens,
2826        dupes_min_lines: cli.dupes_min_lines,
2827        dupes_min_occurrences: cli.dupes_min_occurrences,
2828        dupes_skip_local: cli.dupes_skip_local,
2829        dupes_cross_language: cli.dupes_cross_language,
2830        dupes_ignore_imports: resolve_ignore_imports(
2831            cli.dupes_ignore_imports,
2832            cli.dupes_no_ignore_imports,
2833        ),
2834        score: cli.score || cli.trend,
2835        trend: cli.trend,
2836        save_snapshot: cli.save_snapshot.as_ref(),
2837        coverage: coverage_inputs.coverage.as_deref(),
2838        coverage_root: coverage_inputs.coverage_root.as_deref(),
2839        include_entry_exports: cli.include_entry_exports,
2840        regression_opts: dispatch.regression_opts(
2841            cli.changed_since.is_some()
2842                || cli.workspace.is_some()
2843                || cli.changed_workspaces.is_some(),
2844        ),
2845    })
2846}
2847
2848fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2849    let cli = dispatch.cli;
2850    let root = dispatch.root;
2851    let output = dispatch.output;
2852    let quiet = dispatch.quiet;
2853    match command {
2854        check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2855        Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2856        Command::Inspect {
2857            file,
2858            symbol,
2859            symbol_chain,
2860            churn,
2861        } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2862        Command::Trace {
2863            symbol,
2864            callers,
2865            callees,
2866            depth,
2867        } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2868        fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2869        init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2870        Command::Hooks { subcommand } => {
2871            run_hooks_command(root, subcommand, output, dispatch.json_style)
2872        }
2873        Command::Ci { subcommand } => {
2874            ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
2875        }
2876        Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
2877        Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
2878        Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
2879        Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
2880        Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2881        Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2882        Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2883        Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
2884            root,
2885            explicit_config: cli.config.as_deref(),
2886            path_only: path,
2887            output,
2888            quiet,
2889            json_style: dispatch.json_style,
2890            load_options: fallow_config::ConfigLoadOptions {
2891                allow_remote_extends: cli.allow_remote_extends,
2892            },
2893        }),
2894        Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
2895        list @ (Command::Workspaces | Command::List { .. }) => {
2896            dispatch_list_command(&list, dispatch)
2897        }
2898        dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2899        health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2900        Command::Flags { top } => dispatch_flags_command(dispatch, top),
2901        Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2902        Command::Explain { issue_type } => {
2903            explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
2904        }
2905        audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
2906        Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
2907        Command::DecisionSurface { max_decisions } => {
2908            dispatch_decision_surface(dispatch, max_decisions)
2909        }
2910        Command::Impact {
2911            subcommand,
2912            all,
2913            sort,
2914            limit,
2915        } => dispatch_impact(
2916            root,
2917            quiet,
2918            output,
2919            dispatch.json_style,
2920            subcommand,
2921            ImpactCrossRepoOpts { all, sort, limit },
2922        ),
2923        security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
2924        Command::Report { from } => cli_report::run_report(&from, output, root),
2925        Command::Schema => unreachable!("handled above"),
2926        migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
2927        Command::License { subcommand } => {
2928            dispatch_license_command(subcommand, output, dispatch.json_style)
2929        }
2930        Command::Telemetry { .. } => unreachable!("handled before root validation"),
2931        Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
2932        setup_hooks @ Command::SetupHooks { .. } => {
2933            dispatch_setup_hooks_command(&setup_hooks, dispatch)
2934        }
2935    }
2936}
2937
2938/// Destructure the `Command::Check` arm and forward to `dispatch_check`.
2939fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2940    let filters = check_issue_filters(&command);
2941    let Command::Check {
2942        include_dupes,
2943        trace,
2944        trace_file,
2945        trace_dependency,
2946        impact_closure,
2947        top,
2948        file,
2949        ..
2950    } = command
2951    else {
2952        unreachable!("check dispatcher only handles check commands");
2953    };
2954
2955    dispatch_check(
2956        dispatch,
2957        &CheckDispatchArgs {
2958            filters,
2959            trace_opts: TraceOptions {
2960                trace_export: trace,
2961                trace_file,
2962                trace_dependency,
2963                impact_closure,
2964                performance: dispatch.cli.performance,
2965            },
2966            include_dupes,
2967            top,
2968            file,
2969        },
2970    )
2971}
2972
2973/// Map the `Command::Check` filter flags onto `IssueFilters`. Reads the flags by
2974/// reference (all `Copy` bools) so the caller can still move the non-filter
2975/// fields out of the same `Command` value afterwards. Split into two halves to
2976/// keep each builder within the unit-size limit.
2977fn check_issue_filters(command: &Command) -> IssueFilters {
2978    check_issue_filters_framework(command, &check_issue_filters_core(command))
2979}
2980
2981/// First half of the `IssueFilters` mapping: core/general filter flags over a
2982/// `Default` base. The framework/catalog half layers on top via struct update.
2983fn check_issue_filters_core(command: &Command) -> IssueFilters {
2984    let Command::Check {
2985        unused_files,
2986        unused_exports,
2987        unused_deps,
2988        unused_types,
2989        private_type_leaks,
2990        unused_enum_members,
2991        unused_class_members,
2992        unresolved_imports,
2993        unlisted_deps,
2994        duplicate_exports,
2995        circular_deps,
2996        re_export_cycles,
2997        boundary_violations,
2998        policy_violations,
2999        stale_suppressions,
3000        ..
3001    } = command
3002    else {
3003        unreachable!("check filter builder only handles check commands");
3004    };
3005
3006    let mut filters = IssueFilters::default();
3007    for (flag, active) in [
3008        ("--unused-files", *unused_files),
3009        ("--unused-exports", *unused_exports),
3010        ("--unused-deps", *unused_deps),
3011        ("--unused-types", *unused_types),
3012        ("--private-type-leaks", *private_type_leaks),
3013        ("--unused-enum-members", *unused_enum_members),
3014        ("--unused-class-members", *unused_class_members),
3015        ("--unresolved-imports", *unresolved_imports),
3016        ("--unlisted-deps", *unlisted_deps),
3017        ("--duplicate-exports", *duplicate_exports),
3018        ("--circular-deps", *circular_deps),
3019        ("--re-export-cycles", *re_export_cycles),
3020        ("--boundary-violations", *boundary_violations),
3021        ("--policy-violations", *policy_violations),
3022        ("--stale-suppressions", *stale_suppressions),
3023    ] {
3024        enable_check_filter(&mut filters, flag, active);
3025    }
3026    filters
3027}
3028
3029/// Second half of the `IssueFilters` mapping: framework/component, store, svelte,
3030/// catalog, and dependency-override flags, layered onto the core `base`.
3031fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3032    let Command::Check {
3033        unused_store_members,
3034        unprovided_injects,
3035        unrendered_components,
3036        unused_component_props,
3037        unused_component_emits,
3038        unused_component_inputs,
3039        unused_component_outputs,
3040        unused_svelte_events,
3041        unused_server_actions,
3042        unused_load_data_keys,
3043        unused_catalog_entries,
3044        empty_catalog_groups,
3045        unresolved_catalog_references,
3046        unused_dependency_overrides,
3047        misconfigured_dependency_overrides,
3048        ..
3049    } = command
3050    else {
3051        unreachable!("check filter builder only handles check commands");
3052    };
3053
3054    let mut filters = base.clone();
3055    for (flag, active) in [
3056        ("--unused-store-members", *unused_store_members),
3057        ("--unprovided-injects", *unprovided_injects),
3058        ("--unrendered-components", *unrendered_components),
3059        ("--unused-component-props", *unused_component_props),
3060        ("--unused-component-emits", *unused_component_emits),
3061        ("--unused-component-inputs", *unused_component_inputs),
3062        ("--unused-component-outputs", *unused_component_outputs),
3063        ("--unused-svelte-events", *unused_svelte_events),
3064        ("--unused-server-actions", *unused_server_actions),
3065        ("--unused-load-data-keys", *unused_load_data_keys),
3066        ("--unused-catalog-entries", *unused_catalog_entries),
3067        ("--empty-catalog-groups", *empty_catalog_groups),
3068        (
3069            "--unresolved-catalog-references",
3070            *unresolved_catalog_references,
3071        ),
3072        (
3073            "--unused-dependency-overrides",
3074            *unused_dependency_overrides,
3075        ),
3076        (
3077            "--misconfigured-dependency-overrides",
3078            *misconfigured_dependency_overrides,
3079        ),
3080    ] {
3081        enable_check_filter(&mut filters, flag, active);
3082    }
3083    filters
3084}
3085
3086fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3087    if active {
3088        assert!(
3089            filters.enable_cli_filter_flag(flag),
3090            "check command uses unregistered dead-code filter flag {flag}"
3091        );
3092    }
3093}
3094
3095fn dispatch_inspect_command(
3096    dispatch: &DispatchContext<'_>,
3097    file: Option<String>,
3098    symbol: Option<String>,
3099    symbol_chain: bool,
3100    churn: bool,
3101) -> ExitCode {
3102    let target = match (file, symbol) {
3103        (Some(file), None) => inspect::InspectTarget::File { file },
3104        (None, Some(symbol)) => match symbol.rsplit_once(':') {
3105            Some((file, export_name))
3106                if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3107            {
3108                inspect::InspectTarget::Symbol {
3109                    file: file.to_string(),
3110                    export_name: export_name.to_string(),
3111                }
3112            }
3113            _ => {
3114                return emit_error(
3115                    "--symbol must be formatted as FILE:EXPORT",
3116                    2,
3117                    dispatch.output,
3118                );
3119            }
3120        },
3121        _ => {
3122            return emit_error(
3123                "inspect requires exactly one of --file or --symbol",
3124                2,
3125                dispatch.output,
3126            );
3127        }
3128    };
3129
3130    let churn_config = if churn {
3131        match load_config_for_analysis(
3132            dispatch.root,
3133            &dispatch.cli.config,
3134            ConfigLoadOptions {
3135                output: dispatch.output,
3136                no_cache: dispatch.cli.no_cache,
3137                threads: dispatch.threads,
3138                production_override: None,
3139                quiet: dispatch.quiet,
3140                allow_remote_extends: dispatch.cli.allow_remote_extends,
3141            },
3142            fallow_config::ProductionAnalysis::Health,
3143        ) {
3144            Ok(config) => Some(config),
3145            Err(code) => return code,
3146        }
3147    } else {
3148        None
3149    };
3150
3151    inspect::run_inspect(&inspect::InspectOptions {
3152        root: dispatch.root,
3153        config_path: dispatch.cli.config.as_ref(),
3154        output: dispatch.output,
3155        json_style: dispatch.json_style,
3156        no_cache: dispatch.cli.no_cache,
3157        no_production: dispatch.cli.no_production,
3158        max_file_size: dispatch.cli.max_file_size,
3159        threads: dispatch.threads,
3160        quiet: dispatch.quiet,
3161        production: dispatch.cli.production,
3162        workspace: dispatch.cli.workspace.as_ref(),
3163        target,
3164        churn_cache_dir: churn_config
3165            .as_ref()
3166            .map(|config| config.cache_dir.as_path()),
3167        symbol_chain,
3168    })
3169}
3170
3171fn dispatch_trace_command(
3172    dispatch: &DispatchContext<'_>,
3173    symbol: String,
3174    callers: bool,
3175    callees: bool,
3176    depth: Option<u32>,
3177) -> ExitCode {
3178    trace_chain::run_trace(&trace_chain::TraceChainOptions {
3179        root: dispatch.root,
3180        config_path: &dispatch.cli.config,
3181        output: dispatch.output,
3182        json_style: dispatch.json_style,
3183        no_cache: dispatch.cli.no_cache,
3184        threads: dispatch.threads,
3185        quiet: dispatch.quiet,
3186        allow_remote_extends: dispatch.cli.allow_remote_extends,
3187        target: symbol,
3188        callers,
3189        callees,
3190        depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3191    })
3192}
3193
3194fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3195    let Command::Security {
3196        subcommand,
3197        runtime_coverage,
3198        min_invocations_hot,
3199        file,
3200        gate,
3201        surface,
3202    } = command
3203    else {
3204        unreachable!("security dispatcher only handles security commands");
3205    };
3206
3207    let gate = gate.map(security::SecurityGateArg::into_mode);
3208    let cli = dispatch.cli;
3209    let (output, _quiet, fail_on_issues) =
3210        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3211    let derived_flags = SecurityDerivedFlagState {
3212        output,
3213        json_style: dispatch.json_style,
3214        ci: cli.ci,
3215        fail_on_issues,
3216        sarif_file: cli.sarif_file.as_deref(),
3217        summary: cli.summary,
3218        explain: cli.explain,
3219        runtime_coverage: runtime_coverage.as_deref(),
3220        min_invocations_hot,
3221        file: file.as_slice(),
3222        gate,
3223        surface,
3224    };
3225    if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3226        return code;
3227    }
3228
3229    let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3230    run_security_blind_spots_or_default(
3231        dispatch,
3232        &SecurityRunInputs {
3233            scoped_files: &scoped_files,
3234            subcommand: &subcommand,
3235            runtime_coverage: runtime_coverage.as_deref(),
3236            min_invocations_hot,
3237            gate,
3238            surface,
3239        },
3240        &derived_flags,
3241    )
3242}
3243
3244/// Inputs threaded from the security dispatcher into the run step. Borrows the
3245/// scoped file list and subcommand so they outlive the `SecurityOptions`.
3246struct SecurityRunInputs<'a> {
3247    scoped_files: &'a [PathBuf],
3248    subcommand: &'a Option<SecuritySubcommand>,
3249    runtime_coverage: Option<&'a Path>,
3250    min_invocations_hot: u64,
3251    gate: Option<security::SecurityGateMode>,
3252    surface: bool,
3253}
3254
3255/// Build `SecurityOptions` and run either the blind-spots or default analysis.
3256fn run_security_blind_spots_or_default(
3257    dispatch: &DispatchContext<'_>,
3258    inputs: &SecurityRunInputs<'_>,
3259    derived_flags: &SecurityDerivedFlagState<'_>,
3260) -> ExitCode {
3261    let cli = dispatch.cli;
3262    let (output, quiet, fail_on_issues) =
3263        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3264    let opts = security::SecurityOptions {
3265        root: dispatch.root,
3266        config_path: &cli.config,
3267        output,
3268        json_style: dispatch.json_style,
3269        no_cache: cli.no_cache,
3270        threads: dispatch.threads,
3271        quiet,
3272        allow_remote_extends: cli.allow_remote_extends,
3273        fail_on_issues,
3274        sarif_file: cli.sarif_file.as_deref(),
3275        summary: cli.summary,
3276        changed_since: cli.changed_since.as_deref(),
3277        use_shared_diff_index: true,
3278        workspace: cli.workspace.as_deref(),
3279        changed_workspaces: cli.changed_workspaces.as_deref(),
3280        file: inputs.scoped_files,
3281        surface: inputs.surface,
3282        gate: inputs.gate,
3283        runtime_coverage: inputs.runtime_coverage,
3284        min_invocations_hot: inputs.min_invocations_hot,
3285        explain: cli.explain,
3286    };
3287    if matches!(
3288        inputs.subcommand,
3289        Some(SecuritySubcommand::BlindSpots { .. })
3290    ) {
3291        if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3292            return code;
3293        }
3294        security::run_blind_spots(&opts)
3295    } else {
3296        security::run(&opts)
3297    }
3298}
3299
3300/// Handle `fallow security survivors` as an early return. Returns `Some(code)`
3301/// when the subcommand is `survivors` (validated then run); `None` otherwise.
3302fn try_run_security_survivors(
3303    subcommand: Option<&SecuritySubcommand>,
3304    flags: &SecurityDerivedFlagState<'_>,
3305) -> Option<ExitCode> {
3306    let Some(SecuritySubcommand::Survivors {
3307        candidates,
3308        verdicts,
3309        require_verdict_for_each_candidate,
3310    }) = subcommand
3311    else {
3312        return None;
3313    };
3314    if let Some(code) = validate_security_survivors_flags(flags) {
3315        return Some(code);
3316    }
3317    Some(security::run_survivors(
3318        &security::SecuritySurvivorsOptions {
3319            output: flags.output,
3320            json_style: flags.json_style,
3321            candidates,
3322            verdicts,
3323            require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3324        },
3325    ))
3326}
3327
3328/// Build the scoped file list, folding in `blind-spots` extra `--file` values.
3329fn scoped_security_files(
3330    file: &[PathBuf],
3331    subcommand: Option<&SecuritySubcommand>,
3332) -> Vec<PathBuf> {
3333    let mut scoped_files = file.to_vec();
3334    if let Some(SecuritySubcommand::BlindSpots {
3335        file: blind_spot_files,
3336    }) = subcommand
3337    {
3338        scoped_files.extend(blind_spot_files.iter().cloned());
3339    }
3340    scoped_files
3341}
3342
3343struct SecurityDerivedFlagState<'a> {
3344    output: fallow_config::OutputFormat,
3345    json_style: json_style::JsonStyle,
3346    ci: bool,
3347    fail_on_issues: bool,
3348    sarif_file: Option<&'a Path>,
3349    summary: bool,
3350    explain: bool,
3351    runtime_coverage: Option<&'a Path>,
3352    min_invocations_hot: u64,
3353    file: &'a [PathBuf],
3354    gate: Option<security::SecurityGateMode>,
3355    surface: bool,
3356}
3357
3358fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3359    let flag = if flags.ci {
3360        Some("--ci")
3361    } else if flags.fail_on_issues {
3362        Some("--fail-on-issues")
3363    } else if flags.sarif_file.is_some() {
3364        Some("--sarif-file")
3365    } else if flags.summary {
3366        Some("--summary")
3367    } else if flags.explain {
3368        Some("--explain")
3369    } else if flags.runtime_coverage.is_some() {
3370        Some("--runtime-coverage")
3371    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3372        Some("--min-invocations-hot")
3373    } else if !flags.file.is_empty() {
3374        Some("--file")
3375    } else if flags.gate.is_some() {
3376        Some("--gate")
3377    } else if flags.surface {
3378        Some("--surface")
3379    } else {
3380        None
3381    }?;
3382    Some(emit_error(
3383        &format!("{flag} is not valid with `fallow security survivors`."),
3384        2,
3385        flags.output,
3386    ))
3387}
3388
3389fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3390    let flag = if flags.ci {
3391        Some("--ci")
3392    } else if flags.fail_on_issues {
3393        Some("--fail-on-issues")
3394    } else if flags.sarif_file.is_some() {
3395        Some("--sarif-file")
3396    } else if flags.summary {
3397        Some("--summary")
3398    } else if flags.explain {
3399        Some("--explain")
3400    } else if flags.runtime_coverage.is_some() {
3401        Some("--runtime-coverage")
3402    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3403        Some("--min-invocations-hot")
3404    } else if flags.gate.is_some() {
3405        Some("--gate")
3406    } else if flags.surface {
3407        Some("--surface")
3408    } else {
3409        None
3410    }?;
3411    Some(emit_error(
3412        &format!("{flag} is not valid with `fallow security blind-spots`."),
3413        2,
3414        flags.output,
3415    ))
3416}
3417
3418fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3419    let Command::Dupes {
3420        mode,
3421        min_tokens,
3422        min_lines,
3423        min_occurrences,
3424        threshold,
3425        skip_local,
3426        cross_language,
3427        ignore_imports,
3428        no_ignore_imports,
3429        top,
3430        trace,
3431    } = command
3432    else {
3433        unreachable!("dupes dispatcher only handles dupes commands");
3434    };
3435
3436    dispatch_dupes(
3437        dispatch,
3438        &DupesDispatchArgs {
3439            mode,
3440            min_tokens,
3441            min_lines,
3442            min_occurrences,
3443            threshold,
3444            skip_local,
3445            cross_language,
3446            ignore_imports,
3447            no_ignore_imports,
3448            top,
3449            trace,
3450        },
3451    )
3452}
3453
3454fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3455    let Command::Init {
3456        toml,
3457        agents,
3458        hooks,
3459        branch,
3460        decline,
3461    } = command
3462    else {
3463        unreachable!("init dispatcher only handles init commands");
3464    };
3465
3466    init::run_init(&init::InitOptions {
3467        root,
3468        use_toml: toml,
3469        agents,
3470        hooks,
3471        branch: branch.as_deref(),
3472        decline,
3473        quiet,
3474    })
3475}
3476
3477fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3478    let Command::Fix {
3479        dry_run,
3480        yes,
3481        no_create_config,
3482    } = command
3483    else {
3484        unreachable!("fix dispatcher only handles fix commands");
3485    };
3486
3487    dispatch_fix(
3488        dispatch,
3489        FixDispatchArgs {
3490            dry_run: *dry_run,
3491            yes: *yes,
3492            no_create_config: *no_create_config,
3493        },
3494    )
3495}
3496
3497fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3498    match command {
3499        Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3500        Command::List {
3501            entry_points,
3502            files,
3503            plugins,
3504            boundaries,
3505            workspaces,
3506        } => dispatch_list(
3507            dispatch,
3508            ListDispatchArgs {
3509                entry_points: *entry_points,
3510                files: *files,
3511                plugins: *plugins,
3512                boundaries: *boundaries,
3513                workspaces: *workspaces,
3514            },
3515        ),
3516        _ => unreachable!("list dispatcher only handles list commands"),
3517    }
3518}
3519
3520fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3521    let Command::Migrate {
3522        toml,
3523        jsonc,
3524        dry_run,
3525        from,
3526    } = command
3527    else {
3528        unreachable!("migrate dispatcher only handles migrate commands");
3529    };
3530
3531    migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3532}
3533
3534fn dispatch_license_command(
3535    subcommand: LicenseCli,
3536    output: fallow_config::OutputFormat,
3537    json_style: json_style::JsonStyle,
3538) -> ExitCode {
3539    license::run(&map_license_subcommand(subcommand), output, json_style)
3540}
3541
3542fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3543    match subcommand {
3544        CiTemplateCli::Gitlab { vendor, force } => {
3545            ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3546                vendor_dir: vendor,
3547                force,
3548            })
3549        }
3550    }
3551}
3552
3553fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3554    let cli = dispatch.cli;
3555    coverage::run(
3556        map_coverage_subcommand(subcommand, cli.explain),
3557        &coverage::RunContext {
3558            root: dispatch.root,
3559            config_path: &cli.config,
3560            output: dispatch.output,
3561            json_style: dispatch.json_style,
3562            quiet: dispatch.quiet,
3563            no_cache: cli.no_cache,
3564            threads: dispatch.threads,
3565            explain: cli.explain,
3566            allow_remote_extends: cli.allow_remote_extends,
3567        },
3568    )
3569}
3570
3571fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3572    let Command::Health {
3573        max_cyclomatic,
3574        max_cognitive,
3575        max_crap,
3576        top,
3577        sort,
3578        complexity,
3579        complexity_breakdown,
3580        file_scores,
3581        coverage_gaps,
3582        hotspots,
3583        ownership,
3584        ownership_emails,
3585        targets,
3586        css,
3587        effort,
3588        score,
3589        min_score,
3590        min_severity,
3591        report_only,
3592        since,
3593        min_commits,
3594        save_snapshot,
3595        trend,
3596        coverage,
3597        coverage_root,
3598        runtime_coverage,
3599        min_invocations_hot,
3600        min_observation_volume,
3601        low_traffic_threshold,
3602    } = command
3603    else {
3604        unreachable!("health dispatcher only handles health commands");
3605    };
3606
3607    let ownership = ownership || ownership_emails.is_some();
3608    let hotspots = hotspots || ownership;
3609    let args = HealthDispatchArgs {
3610        max_cyclomatic,
3611        max_cognitive,
3612        max_crap,
3613        top,
3614        sort,
3615        complexity,
3616        complexity_breakdown,
3617        file_scores,
3618        coverage_gaps,
3619        hotspots,
3620        ownership,
3621        ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3622        targets,
3623        css,
3624        effort,
3625        score,
3626        min_score,
3627        min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3628        report_only,
3629        since: since.as_deref(),
3630        min_commits,
3631        save_snapshot: save_snapshot.as_ref(),
3632        trend,
3633        coverage: coverage.as_deref(),
3634        coverage_root: coverage_root.as_deref(),
3635        runtime_coverage: runtime_coverage.as_deref(),
3636        min_invocations_hot,
3637        min_observation_volume,
3638        low_traffic_threshold,
3639    };
3640    dispatch_health(dispatch, &args)
3641}
3642
3643fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3644    let Command::SetupHooks {
3645        agent,
3646        dry_run,
3647        force,
3648        user,
3649        gitignore_claude,
3650        uninstall,
3651    } = command
3652    else {
3653        unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3654    };
3655
3656    setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3657        root: dispatch.root,
3658        agent: *agent,
3659        dry_run: *dry_run,
3660        force: *force,
3661        user: *user,
3662        gitignore_claude: *gitignore_claude,
3663        uninstall: *uninstall,
3664    })
3665}
3666
3667fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3668    let Command::Audit {
3669        production_dead_code,
3670        production_health,
3671        production_dupes,
3672        dead_code_baseline,
3673        health_baseline,
3674        dupes_baseline,
3675        max_crap,
3676        coverage,
3677        coverage_root,
3678        no_css,
3679        css_deep,
3680        no_css_deep,
3681        gate,
3682        runtime_coverage,
3683        min_invocations_hot,
3684        gate_marker,
3685        brief,
3686        max_decisions,
3687        walkthrough_guide,
3688        walkthrough_file,
3689        walkthrough,
3690        mark_viewed,
3691        show_cleared,
3692        show_deprioritized,
3693    } = command
3694    else {
3695        unreachable!("audit dispatcher only handles audit commands");
3696    };
3697
3698    // The walkthrough flags imply the brief path (the guide digest + the
3699    // graph-snapshot pin are brief-path data).
3700    let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3701
3702    dispatch_audit(
3703        dispatch,
3704        &AuditDispatchArgs {
3705            production_dead_code,
3706            production_health,
3707            production_dupes,
3708            dead_code_baseline,
3709            health_baseline,
3710            dupes_baseline,
3711            max_crap,
3712            coverage,
3713            coverage_root,
3714            no_css,
3715            css_deep,
3716            no_css_deep,
3717            gate,
3718            runtime_coverage,
3719            min_invocations_hot,
3720            gate_marker,
3721            brief,
3722            max_decisions,
3723            walkthrough_guide,
3724            walkthrough_file,
3725            walkthrough,
3726            mark_viewed,
3727            show_cleared,
3728            show_deprioritized,
3729        },
3730    )
3731}
3732
3733fn dispatch_audit_cache_command(
3734    dispatch: &DispatchContext<'_>,
3735    subcommand: &AuditCacheCli,
3736) -> ExitCode {
3737    match subcommand {
3738        AuditCacheCli::Remove { dry_run, yes } => {
3739            if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
3740                return emit_error(
3741                    "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
3742                    2,
3743                    dispatch.output,
3744                );
3745            }
3746            match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
3747                Ok(report) => {
3748                    let action = if *dry_run { "would remove" } else { "removed" };
3749                    if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
3750                        let value = serde_json::json!({
3751                            "kind": "audit-cache-remove",
3752                            "schema_version": 1,
3753                            "command": "audit-cache remove",
3754                            "root": dispatch.root,
3755                            "dry_run": report.dry_run,
3756                            "found": report.found,
3757                            "would_remove": report.found.saturating_sub(report.skipped),
3758                            "removed": report.removed,
3759                            "skipped": report.skipped,
3760                            "complete": report.skipped == 0,
3761                        });
3762                        let output_code = report::emit_report_json(
3763                            &value,
3764                            "audit cache removal",
3765                            dispatch.json_style,
3766                        );
3767                        if output_code != ExitCode::SUCCESS {
3768                            return output_code;
3769                        }
3770                    } else if !dispatch.quiet {
3771                        println!(
3772                            "audit cache: {action} {}, skipped {} for {}",
3773                            if *dry_run {
3774                                report.found.saturating_sub(report.skipped)
3775                            } else {
3776                                report.removed
3777                            },
3778                            report.skipped,
3779                            dispatch.root.display(),
3780                        );
3781                    }
3782                    if report.skipped == 0 {
3783                        ExitCode::SUCCESS
3784                    } else {
3785                        ExitCode::from(2)
3786                    }
3787                }
3788                Err(error) => emit_error(
3789                    &format!(
3790                        "failed to remove audit caches for {}: {error}",
3791                        dispatch.root.display()
3792                    ),
3793                    2,
3794                    dispatch.output,
3795                ),
3796            }
3797        }
3798    }
3799}
3800
3801fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3802    let cli = dispatch.cli;
3803    let root = dispatch.root;
3804    let output = dispatch.output;
3805    let quiet = dispatch.quiet;
3806    let threads = dispatch.threads;
3807    let production = match resolve_production_modes(cli, root, output, false, false, false) {
3808        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3809        Err(code) => return code,
3810    };
3811    flags::run_flags(&flags::FlagsOptions {
3812        root,
3813        config_path: &cli.config,
3814        output,
3815        json_style: dispatch.json_style,
3816        no_cache: cli.no_cache,
3817        threads,
3818        quiet,
3819        allow_remote_extends: cli.allow_remote_extends,
3820        production,
3821        workspace: cli.workspace.as_deref(),
3822        changed_workspaces: cli.changed_workspaces.as_deref(),
3823        changed_since: cli.changed_since.as_deref(),
3824        explain: cli.explain,
3825        top,
3826    })
3827}
3828
3829fn dispatch_suppressions_command(
3830    dispatch: &DispatchContext<'_>,
3831    file: &[std::path::PathBuf],
3832) -> ExitCode {
3833    let cli = dispatch.cli;
3834    let root = dispatch.root;
3835    let output = dispatch.output;
3836    let production = match resolve_production_modes(cli, root, output, false, false, false) {
3837        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3838        Err(code) => return code,
3839    };
3840    suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3841        root,
3842        config_path: &cli.config,
3843        output,
3844        json_style: dispatch.json_style,
3845        no_cache: cli.no_cache,
3846        threads: dispatch.threads,
3847        quiet: dispatch.quiet,
3848        allow_remote_extends: cli.allow_remote_extends,
3849        production,
3850        workspace: cli.workspace.as_deref(),
3851        changed_workspaces: cli.changed_workspaces.as_deref(),
3852        changed_since: cli.changed_since.as_deref(),
3853        file,
3854    })
3855}
3856
3857fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3858    guard::run_guard(&guard::GuardOptions {
3859        root: dispatch.root,
3860        config_path: &dispatch.cli.config,
3861        output: dispatch.output,
3862        json_style: dispatch.json_style,
3863        quiet: dispatch.quiet,
3864        allow_remote_extends: dispatch.cli.allow_remote_extends,
3865        files,
3866    })
3867}
3868
3869fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3870    let ctx = rule_pack::RulePackContext {
3871        root: dispatch.root,
3872        config_path: &dispatch.cli.config,
3873        output: dispatch.output,
3874        json_style: dispatch.json_style,
3875        quiet: dispatch.quiet,
3876        no_cache: dispatch.cli.no_cache,
3877        threads: Some(dispatch.threads),
3878        allow_remote_extends: dispatch.cli.allow_remote_extends,
3879    };
3880    rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3881}
3882
3883fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3884    match subcommand {
3885        RulePackCli::Init {
3886            name,
3887            template,
3888            dir,
3889            no_config,
3890        } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3891            name,
3892            template,
3893            dir,
3894            no_config,
3895        }),
3896        RulePackCli::List => rule_pack::RulePackSubcommand::List,
3897        RulePackCli::Test { pack } => {
3898            rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3899        }
3900        RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3901    }
3902}
3903
3904fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3905    match sub {
3906        LicenseCli::Activate {
3907            jwt,
3908            from_file,
3909            stdin,
3910            trial,
3911            email,
3912        } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3913            raw_jwt: jwt,
3914            from_file,
3915            from_stdin: stdin,
3916            trial,
3917            email,
3918        }),
3919        LicenseCli::Status => license::LicenseSubcommand::Status,
3920        LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3921        LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3922    }
3923}
3924
3925fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3926    match sub {
3927        TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3928        TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3929        TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3930        TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3931    }
3932}
3933
3934fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3935    match sub {
3936        command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3937        command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3938        command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3939        command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3940        command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3941    }
3942}
3943
3944fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3945    let CiCli::PlanPrComment {
3946        body,
3947        marker_id,
3948        clean,
3949        existing_comment_id,
3950        existing_body,
3951    } = command
3952    else {
3953        unreachable!("ci plan-pr-comment mapper called with different variant");
3954    };
3955
3956    ci::CiCommand::PlanPrComment {
3957        body,
3958        marker_id,
3959        clean,
3960        existing_comment_id,
3961        existing_body,
3962    }
3963}
3964
3965fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3966    let CiCli::PostPrComment {
3967        provider,
3968        pr,
3969        mr,
3970        body,
3971        envelope,
3972        marker_id,
3973        clean,
3974        repo,
3975        project_id,
3976        api_url,
3977        dry_run,
3978    } = command
3979    else {
3980        unreachable!("ci post-pr-comment mapper called with different variant");
3981    };
3982
3983    ci::CiCommand::PostPrComment {
3984        provider: map_ci_provider(provider),
3985        target: pr.or(mr),
3986        body,
3987        envelope,
3988        marker_id,
3989        clean,
3990        repo,
3991        project_id,
3992        api_url,
3993        dry_run,
3994    }
3995}
3996
3997fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
3998    let CiCli::PostReview {
3999        provider,
4000        pr,
4001        mr,
4002        envelope,
4003        repo,
4004        project_id,
4005        api_url,
4006        dry_run,
4007    } = command
4008    else {
4009        unreachable!("ci post-review mapper called with different variant");
4010    };
4011
4012    ci::CiCommand::PostReview {
4013        provider: map_ci_provider(provider),
4014        target: pr.or(mr),
4015        envelope,
4016        repo,
4017        project_id,
4018        api_url,
4019        dry_run,
4020    }
4021}
4022
4023fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4024    let CiCli::PostCheckRun {
4025        provider,
4026        decision,
4027        repo,
4028        head_sha,
4029        api_url,
4030        split_gates,
4031        dry_run,
4032    } = command
4033    else {
4034        unreachable!("ci post-check-run mapper called with different variant");
4035    };
4036
4037    ci::CiCommand::PostCheckRun {
4038        provider: map_ci_provider(provider),
4039        decision,
4040        repo,
4041        head_sha,
4042        api_url,
4043        split_gates,
4044        dry_run,
4045    }
4046}
4047
4048fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4049    let CiCli::ReconcileReview {
4050        provider,
4051        pr,
4052        mr,
4053        envelope,
4054        repo,
4055        project_id,
4056        api_url,
4057        dry_run,
4058    } = command
4059    else {
4060        unreachable!("ci reconcile-review mapper called with different variant");
4061    };
4062
4063    ci::CiCommand::ReconcileReview {
4064        provider: map_ci_provider(provider),
4065        target: pr.or(mr),
4066        envelope,
4067        repo,
4068        project_id,
4069        api_url,
4070        dry_run,
4071    }
4072}
4073
4074fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4075    match provider {
4076        CiProviderArg::Github => ci::CiProvider::Github,
4077        CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4078    }
4079}
4080
4081fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4082    match sub {
4083        CoverageCli::Setup {
4084            yes,
4085            non_interactive,
4086            json,
4087        } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4088        CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4089        CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4090        CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4091        CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4092    }
4093}
4094
4095fn map_coverage_setup(
4096    yes: bool,
4097    non_interactive: bool,
4098    json: bool,
4099    explain: bool,
4100) -> coverage::CoverageSubcommand {
4101    coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4102        yes,
4103        non_interactive: non_interactive || json,
4104        json,
4105        explain,
4106    })
4107}
4108
4109fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4110    let CoverageCli::Analyze {
4111        runtime_coverage,
4112        cloud,
4113        api_key,
4114        api_endpoint,
4115        repo,
4116        project_id,
4117        coverage_period,
4118        environment,
4119        commit_sha,
4120        production,
4121        min_invocations_hot,
4122        min_observation_volume,
4123        low_traffic_threshold,
4124        top,
4125        blast_radius,
4126        importance,
4127    } = sub
4128    else {
4129        unreachable!("coverage analyze mapper called with non-analyze variant");
4130    };
4131    coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4132        runtime_coverage: runtime_coverage.clone(),
4133        cloud: *cloud,
4134        api_key: api_key.clone(),
4135        api_endpoint: api_endpoint.clone(),
4136        repo: repo.clone(),
4137        project_id: project_id.clone(),
4138        coverage_period: *coverage_period,
4139        environment: environment.clone(),
4140        commit_sha: commit_sha.clone(),
4141        production: *production,
4142        min_invocations_hot: *min_invocations_hot,
4143        min_observation_volume: *min_observation_volume,
4144        low_traffic_threshold: *low_traffic_threshold,
4145        top: *top,
4146        blast_radius: *blast_radius,
4147        importance: *importance,
4148    })
4149}
4150
4151fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4152    let CoverageCli::UploadInventory {
4153        api_key,
4154        api_endpoint,
4155        project_id,
4156        git_sha,
4157        allow_dirty,
4158        exclude_paths,
4159        path_prefix,
4160        dry_run,
4161        with_callers,
4162        ignore_upload_errors,
4163    } = sub
4164    else {
4165        unreachable!("coverage inventory mapper called with non-inventory variant");
4166    };
4167    coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4168        api_key: api_key.clone(),
4169        api_endpoint: api_endpoint.clone(),
4170        project_id: project_id.clone(),
4171        git_sha: git_sha.clone(),
4172        allow_dirty: *allow_dirty,
4173        exclude_paths: exclude_paths.clone(),
4174        path_prefix: path_prefix.clone(),
4175        dry_run: *dry_run,
4176        with_callers: *with_callers,
4177        ignore_upload_errors: *ignore_upload_errors,
4178    })
4179}
4180
4181fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4182    let CoverageCli::UploadSourceMaps {
4183        dir,
4184        include,
4185        exclude,
4186        repo,
4187        git_sha,
4188        endpoint,
4189        strip_path,
4190        dry_run,
4191        concurrency,
4192        fail_fast,
4193    } = sub
4194    else {
4195        unreachable!("coverage source-map mapper called with non-source-map variant");
4196    };
4197    coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4198        dir: dir.clone(),
4199        include: include.clone(),
4200        exclude: exclude.clone(),
4201        repo: repo.clone(),
4202        git_sha: git_sha.clone(),
4203        endpoint: endpoint.clone(),
4204        strip_path: *strip_path,
4205        dry_run: *dry_run,
4206        concurrency: *concurrency,
4207        fail_fast: *fail_fast,
4208    })
4209}
4210
4211fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4212    let CoverageCli::UploadStaticFindings {
4213        api_key,
4214        api_endpoint,
4215        project_id,
4216        git_sha,
4217        allow_dirty,
4218        dry_run,
4219        ignore_upload_errors,
4220    } = sub
4221    else {
4222        unreachable!("coverage static-findings mapper called with non-static variant");
4223    };
4224    coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4225        api_key: api_key.clone(),
4226        api_endpoint: api_endpoint.clone(),
4227        project_id: project_id.clone(),
4228        git_sha: git_sha.clone(),
4229        allow_dirty: *allow_dirty,
4230        dry_run: *dry_run,
4231        ignore_upload_errors: *ignore_upload_errors,
4232    })
4233}
4234
4235struct CheckDispatchArgs {
4236    filters: IssueFilters,
4237    trace_opts: TraceOptions,
4238    include_dupes: bool,
4239    top: Option<usize>,
4240    file: Vec<std::path::PathBuf>,
4241}
4242
4243#[derive(Clone, Copy)]
4244struct ListDispatchArgs {
4245    entry_points: bool,
4246    files: bool,
4247    plugins: bool,
4248    boundaries: bool,
4249    workspaces: bool,
4250}
4251
4252impl ListDispatchArgs {
4253    fn workspaces() -> Self {
4254        Self {
4255            entry_points: false,
4256            files: false,
4257            plugins: false,
4258            boundaries: false,
4259            workspaces: true,
4260        }
4261    }
4262}
4263
4264fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4265    let cli = dispatch.cli;
4266    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4267        Ok(production) => production,
4268        Err(code) => return code,
4269    };
4270    watch::run_watch(&watch::WatchOptions {
4271        root: dispatch.root,
4272        config_path: &cli.config,
4273        output: dispatch.output,
4274        json_style: dispatch.json_style,
4275        no_cache: cli.no_cache,
4276        threads: dispatch.threads,
4277        quiet: dispatch.quiet,
4278        allow_remote_extends: cli.allow_remote_extends,
4279        production,
4280        clear_screen: !no_clear,
4281        explain: cli.explain,
4282        include_entry_exports: cli.include_entry_exports,
4283    })
4284}
4285
4286#[derive(Clone, Copy)]
4287struct FixDispatchArgs {
4288    dry_run: bool,
4289    yes: bool,
4290    no_create_config: bool,
4291}
4292
4293fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4294    let cli = dispatch.cli;
4295    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4296        Ok(production) => production,
4297        Err(code) => return code,
4298    };
4299    fix::run_fix(&fix::FixOptions {
4300        root: dispatch.root,
4301        config_path: &cli.config,
4302        output: dispatch.output,
4303        json_style: dispatch.json_style,
4304        no_cache: cli.no_cache,
4305        threads: dispatch.threads,
4306        quiet: dispatch.quiet,
4307        allow_remote_extends: cli.allow_remote_extends,
4308        dry_run: args.dry_run,
4309        yes: args.yes,
4310        production,
4311        no_create_config: args.no_create_config,
4312    })
4313}
4314
4315fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4316    let cli = dispatch.cli;
4317    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4318        Ok(production) => production,
4319        Err(code) => return code,
4320    };
4321    list::run_list(&ListOptions {
4322        root: dispatch.root,
4323        config_path: &cli.config,
4324        output: dispatch.output,
4325        json_style: dispatch.json_style,
4326        threads: dispatch.threads,
4327        no_cache: cli.no_cache,
4328        entry_points: args.entry_points,
4329        files: args.files,
4330        plugins: args.plugins,
4331        boundaries: args.boundaries,
4332        workspaces: args.workspaces,
4333        production,
4334        allow_remote_extends: cli.allow_remote_extends,
4335    })
4336}
4337
4338fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4339    let cli = dispatch.cli;
4340    let (output, quiet, fail_on_issues) =
4341        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4342    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4343        Ok(production) => production,
4344        Err(code) => return code,
4345    };
4346    check::run_check(&CheckOptions {
4347        root: dispatch.root,
4348        config_path: &cli.config,
4349        output,
4350        json_style: dispatch.json_style,
4351        no_cache: cli.no_cache,
4352        threads: dispatch.threads,
4353        quiet,
4354        allow_remote_extends: cli.allow_remote_extends,
4355        fail_on_issues,
4356        filters: &args.filters,
4357        changed_since: cli.changed_since.as_deref(),
4358        diff_index: None,
4359        use_shared_diff_index: true,
4360        baseline: cli.baseline.as_deref(),
4361        save_baseline: cli.save_baseline.as_deref(),
4362        sarif_file: cli.sarif_file.as_deref(),
4363        production,
4364        production_override: Some(production),
4365        workspace: cli.workspace.as_deref(),
4366        changed_workspaces: cli.changed_workspaces.as_deref(),
4367        group_by: cli.group_by,
4368        include_dupes: args.include_dupes,
4369        trace_opts: &args.trace_opts,
4370        explain: cli.explain,
4371        top: args.top,
4372        file: &args.file,
4373        include_entry_exports: cli.include_entry_exports,
4374        summary: cli.summary,
4375        regression_opts: dispatch.regression_opts(
4376            cli.changed_since.is_some()
4377                || cli.workspace.is_some()
4378                || cli.changed_workspaces.is_some()
4379                || !args.file.is_empty(),
4380        ),
4381        retain_modules_for_health: false,
4382        defer_performance: false,
4383    })
4384}
4385
4386/// Resolve the three-state `ignoreImports` CLI override from the opt-in /
4387/// opt-out flag pair. clap's `conflicts_with` guarantees the two are never both
4388/// set, so this maps `--no-ignore-imports` -> `Some(false)`, `--ignore-imports`
4389/// -> `Some(true)`, and neither -> `None` (defer to config, which defaults to
4390/// `true`).
4391fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4392    if no_ignore_imports {
4393        Some(false)
4394    } else if ignore_imports {
4395        Some(true)
4396    } else {
4397        None
4398    }
4399}
4400
4401struct DupesDispatchArgs {
4402    mode: Option<DupesMode>,
4403    min_tokens: Option<usize>,
4404    min_lines: Option<usize>,
4405    min_occurrences: Option<usize>,
4406    threshold: Option<f64>,
4407    skip_local: bool,
4408    cross_language: bool,
4409    ignore_imports: bool,
4410    no_ignore_imports: bool,
4411    top: Option<usize>,
4412    trace: Option<String>,
4413}
4414
4415fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4416    let cli = dispatch.cli;
4417    let (output, quiet, _fail_on_issues) =
4418        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4419    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4420        Ok(production) => production,
4421        Err(code) => return code,
4422    };
4423    dupes::run_dupes(&DupesOptions {
4424        root: dispatch.root,
4425        config_path: &cli.config,
4426        output,
4427        json_style: dispatch.json_style,
4428        no_cache: cli.no_cache,
4429        threads: dispatch.threads,
4430        quiet,
4431        allow_remote_extends: cli.allow_remote_extends,
4432        mode: args.mode,
4433        min_tokens: args.min_tokens,
4434        min_lines: args.min_lines,
4435        min_occurrences: args.min_occurrences,
4436        threshold: args.threshold,
4437        skip_local: args.skip_local,
4438        cross_language: args.cross_language,
4439        ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4440        top: args.top,
4441        baseline_path: cli.baseline.as_deref(),
4442        save_baseline_path: cli.save_baseline.as_deref(),
4443        production,
4444        production_override: Some(production),
4445        trace: args.trace.as_deref(),
4446        changed_since: cli.changed_since.as_deref(),
4447        diff_index: None,
4448        use_shared_diff_index: true,
4449        changed_files: None,
4450        workspace: cli.workspace.as_deref(),
4451        changed_workspaces: cli.changed_workspaces.as_deref(),
4452        explain: cli.explain,
4453        explain_skipped: cli.explain_skipped,
4454        summary: cli.summary,
4455        group_by: cli.group_by,
4456        performance: cli.performance,
4457    })
4458}
4459
4460struct AuditDispatchArgs {
4461    production_dead_code: bool,
4462    production_health: bool,
4463    production_dupes: bool,
4464    dead_code_baseline: Option<PathBuf>,
4465    health_baseline: Option<PathBuf>,
4466    dupes_baseline: Option<PathBuf>,
4467    max_crap: Option<f64>,
4468    coverage: Option<PathBuf>,
4469    coverage_root: Option<PathBuf>,
4470    no_css: bool,
4471    css_deep: bool,
4472    no_css_deep: bool,
4473    gate: Option<AuditGateArg>,
4474    runtime_coverage: Option<PathBuf>,
4475    min_invocations_hot: u64,
4476    gate_marker: Option<String>,
4477    brief: bool,
4478    max_decisions: usize,
4479    /// Emit the agent-contract walkthrough guide instead of the brief body.
4480    walkthrough_guide: bool,
4481    /// Post-validate an agent's judgment JSON from this path against the
4482    /// live graph.
4483    walkthrough_file: Option<PathBuf>,
4484    /// Render the existing walkthrough guide as a staged human/markdown tour.
4485    walkthrough: bool,
4486    /// Changed files to record as VIEWED before rendering the tour.
4487    mark_viewed: Vec<PathBuf>,
4488    /// Expand the Cleared panel (de-prioritized + viewed) in the tour.
4489    show_cleared: bool,
4490    /// Expand the de-prioritized units in the human focus map.
4491    show_deprioritized: bool,
4492}
4493
4494struct ResolvedAuditInputs {
4495    audit_cfg: fallow_config::AuditConfig,
4496    cache_dir: PathBuf,
4497    production: ProductionModes,
4498    dead_code_baseline: Option<PathBuf>,
4499    health_baseline: Option<PathBuf>,
4500    dupes_baseline: Option<PathBuf>,
4501    coverage: Option<PathBuf>,
4502}
4503
4504fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4505    let cli = dispatch.cli;
4506    let output = dispatch.output;
4507
4508    if cli.baseline.is_some() || cli.save_baseline.is_some() {
4509        return emit_error(
4510            "audit uses per-analysis baselines. Use --dead-code-baseline, --health-baseline, or --dupes-baseline (or save them with `fallow dead-code|health|dupes --save-baseline <file>`)",
4511            2,
4512            output,
4513        );
4514    }
4515
4516    let inputs = match resolve_audit_inputs(dispatch, args) {
4517        Ok(inputs) => inputs,
4518        Err(code) => return code,
4519    };
4520
4521    run_resolved_audit(dispatch, args, &inputs)
4522}
4523
4524fn resolve_audit_inputs(
4525    dispatch: &DispatchContext<'_>,
4526    args: &AuditDispatchArgs,
4527) -> Result<ResolvedAuditInputs, ExitCode> {
4528    let cli = dispatch.cli;
4529    let root = dispatch.root;
4530    let output = dispatch.output;
4531    let config = load_config(
4532        root,
4533        &cli.config,
4534        LoadConfigArgs {
4535            output,
4536            no_cache: cli.no_cache,
4537            threads: dispatch.threads,
4538            production: cli.production,
4539            quiet: dispatch.quiet,
4540            allow_remote_extends: cli.allow_remote_extends,
4541        },
4542    )?;
4543    let cache_dir = config.cache_dir.clone();
4544    let audit_cfg = config.audit;
4545    let production = resolve_production_modes(
4546        cli,
4547        root,
4548        output,
4549        args.production_dead_code,
4550        args.production_health,
4551        args.production_dupes,
4552    )?;
4553    let resolved_dead_code_baseline = resolve_audit_baseline_path(
4554        root,
4555        args.dead_code_baseline.as_deref(),
4556        audit_cfg.dead_code_baseline.as_deref(),
4557    );
4558    let resolved_health_baseline = resolve_audit_baseline_path(
4559        root,
4560        args.health_baseline.as_deref(),
4561        audit_cfg.health_baseline.as_deref(),
4562    );
4563    let resolved_dupes_baseline = resolve_audit_baseline_path(
4564        root,
4565        args.dupes_baseline.as_deref(),
4566        audit_cfg.dupes_baseline.as_deref(),
4567    );
4568    let coverage = args
4569        .coverage
4570        .clone()
4571        .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4572
4573    Ok(ResolvedAuditInputs {
4574        audit_cfg,
4575        cache_dir,
4576        production,
4577        dead_code_baseline: resolved_dead_code_baseline,
4578        health_baseline: resolved_health_baseline,
4579        dupes_baseline: resolved_dupes_baseline,
4580        coverage,
4581    })
4582}
4583
4584fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4585    !args.no_css && config.css.unwrap_or(true)
4586}
4587
4588fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4589    audit_css_enabled(config, args)
4590        && !args.no_css_deep
4591        && (args.css_deep || config.css_deep.unwrap_or(true))
4592}
4593
4594fn run_resolved_audit(
4595    dispatch: &DispatchContext<'_>,
4596    args: &AuditDispatchArgs,
4597    inputs: &ResolvedAuditInputs,
4598) -> ExitCode {
4599    let cli = dispatch.cli;
4600    audit::run_audit(
4601        &audit::AuditOptions {
4602            root: dispatch.root,
4603            config_path: &cli.config,
4604            cache_dir: &inputs.cache_dir,
4605            output: dispatch.output,
4606            json_style: dispatch.json_style,
4607            no_cache: cli.no_cache,
4608            threads: dispatch.threads,
4609            quiet: dispatch.quiet,
4610            allow_remote_extends: cli.allow_remote_extends,
4611            changed_since: cli.changed_since.as_deref(),
4612            production: cli.production,
4613            production_dead_code: Some(inputs.production.dead_code),
4614            production_health: Some(inputs.production.health),
4615            production_dupes: Some(inputs.production.dupes),
4616            workspace: cli.workspace.as_deref(),
4617            changed_workspaces: cli.changed_workspaces.as_deref(),
4618            explain: cli.explain,
4619            explain_skipped: cli.explain_skipped,
4620            performance: cli.performance,
4621            group_by: cli.group_by,
4622            dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4623            health_baseline: inputs.health_baseline.as_deref(),
4624            dupes_baseline: inputs.dupes_baseline.as_deref(),
4625            max_crap: args.max_crap,
4626            coverage: inputs.coverage.as_deref(),
4627            coverage_root: args.coverage_root.as_deref(),
4628            gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4629            include_entry_exports: cli.include_entry_exports,
4630            // Styling analytics, including deep cross-file reachability, is on
4631            // by default in `fallow audit`; both layers remain verdict-neutral
4632            // unless a user escalates a styling rule to error.
4633            css: audit_css_enabled(&inputs.audit_cfg, args),
4634            css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4635            runtime_coverage: args.runtime_coverage.as_deref(),
4636            min_invocations_hot: args.min_invocations_hot,
4637            brief: args.brief,
4638            max_decisions: args.max_decisions,
4639            walkthrough_guide: args.walkthrough_guide,
4640            walkthrough: args.walkthrough,
4641            mark_viewed: &args.mark_viewed,
4642            show_cleared: args.show_cleared,
4643            walkthrough_file: args.walkthrough_file.as_deref(),
4644            show_deprioritized: args.show_deprioritized,
4645        },
4646        args.gate_marker.as_deref(),
4647    )
4648}
4649
4650/// Dispatch `fallow decision-surface`: the separable apex. Reuses the audit
4651/// input resolution in brief mode (changed-code scope) with all gating /
4652/// coverage / baseline knobs defaulted, then renders ONLY the decision surface.
4653fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4654    let args = decision_surface_audit_args(max_decisions);
4655    let inputs = match resolve_audit_inputs(dispatch, &args) {
4656        Ok(inputs) => inputs,
4657        Err(code) => return code,
4658    };
4659    audit::run_decision_surface(&decision_surface_audit_options(
4660        dispatch,
4661        &inputs,
4662        max_decisions,
4663    ))
4664}
4665
4666fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4667    AuditDispatchArgs {
4668        production_dead_code: false,
4669        production_health: false,
4670        production_dupes: false,
4671        dead_code_baseline: None,
4672        health_baseline: None,
4673        dupes_baseline: None,
4674        max_crap: None,
4675        coverage: None,
4676        coverage_root: None,
4677        no_css: true,
4678        css_deep: false,
4679        no_css_deep: false,
4680        gate: None,
4681        runtime_coverage: None,
4682        min_invocations_hot: 0,
4683        gate_marker: None,
4684        brief: true,
4685        max_decisions,
4686        walkthrough_guide: false,
4687        walkthrough_file: None,
4688        walkthrough: false,
4689        mark_viewed: Vec::new(),
4690        show_cleared: false,
4691        show_deprioritized: false,
4692    }
4693}
4694
4695fn decision_surface_audit_options<'a>(
4696    dispatch: &'a DispatchContext<'a>,
4697    inputs: &'a ResolvedAuditInputs,
4698    max_decisions: usize,
4699) -> audit::AuditOptions<'a> {
4700    let cli = dispatch.cli;
4701    audit::AuditOptions {
4702        root: dispatch.root,
4703        config_path: &cli.config,
4704        cache_dir: &inputs.cache_dir,
4705        output: dispatch.output,
4706        json_style: dispatch.json_style,
4707        no_cache: cli.no_cache,
4708        threads: dispatch.threads,
4709        quiet: dispatch.quiet,
4710        allow_remote_extends: cli.allow_remote_extends,
4711        changed_since: cli.changed_since.as_deref(),
4712        production: cli.production,
4713        production_dead_code: Some(inputs.production.dead_code),
4714        production_health: Some(inputs.production.health),
4715        production_dupes: Some(inputs.production.dupes),
4716        workspace: cli.workspace.as_deref(),
4717        changed_workspaces: cli.changed_workspaces.as_deref(),
4718        explain: cli.explain,
4719        explain_skipped: cli.explain_skipped,
4720        performance: cli.performance,
4721        group_by: cli.group_by,
4722        dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4723        health_baseline: inputs.health_baseline.as_deref(),
4724        dupes_baseline: inputs.dupes_baseline.as_deref(),
4725        max_crap: None,
4726        coverage: None,
4727        coverage_root: None,
4728        gate: inputs.audit_cfg.gate,
4729        include_entry_exports: cli.include_entry_exports,
4730        // Decision-surface (brief apex) does not render styling; keep it lean.
4731        css: false,
4732        css_deep: false,
4733        runtime_coverage: None,
4734        min_invocations_hot: 0,
4735        brief: true,
4736        max_decisions,
4737        walkthrough_guide: false,
4738        walkthrough: false,
4739        mark_viewed: &[],
4740        show_cleared: false,
4741        walkthrough_file: None,
4742        show_deprioritized: false,
4743    }
4744}
4745
4746struct HealthDispatchArgs<'a> {
4747    max_cyclomatic: Option<u16>,
4748    max_cognitive: Option<u16>,
4749    max_crap: Option<f64>,
4750    top: Option<usize>,
4751    sort: health::SortBy,
4752    complexity: bool,
4753    complexity_breakdown: bool,
4754    file_scores: bool,
4755    coverage_gaps: bool,
4756    hotspots: bool,
4757    ownership: bool,
4758    ownership_emails: Option<fallow_config::EmailMode>,
4759    targets: bool,
4760    css: bool,
4761    effort: Option<EffortFilter>,
4762    score: bool,
4763    min_score: Option<f64>,
4764    min_severity: Option<fallow_output::FindingSeverity>,
4765    report_only: bool,
4766    since: Option<&'a str>,
4767    min_commits: Option<u32>,
4768    save_snapshot: Option<&'a Option<String>>,
4769    trend: bool,
4770    coverage: Option<&'a std::path::Path>,
4771    coverage_root: Option<&'a std::path::Path>,
4772    runtime_coverage: Option<&'a std::path::Path>,
4773    min_invocations_hot: u64,
4774    min_observation_volume: Option<u32>,
4775    low_traffic_threshold: Option<f64>,
4776}
4777
4778struct ResolvedHealthCoverageInputs {
4779    coverage: Option<PathBuf>,
4780    coverage_root: Option<PathBuf>,
4781}
4782
4783fn resolve_health_coverage_inputs(
4784    dispatch: &DispatchContext<'_>,
4785    cli_coverage: Option<&std::path::Path>,
4786    cli_coverage_root: Option<&std::path::Path>,
4787) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4788    let env_coverage = path_from_env("FALLOW_COVERAGE");
4789    let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4790    let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4791    let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4792    let config_health = if needs_config_coverage || needs_config_coverage_root {
4793        Some(
4794            load_config(
4795                dispatch.root,
4796                &dispatch.cli.config,
4797                LoadConfigArgs {
4798                    output: dispatch.output,
4799                    no_cache: dispatch.cli.no_cache,
4800                    threads: dispatch.threads,
4801                    production: dispatch.cli.production,
4802                    quiet: dispatch.quiet,
4803                    allow_remote_extends: dispatch.cli.allow_remote_extends,
4804                },
4805            )?
4806            .health,
4807        )
4808    } else {
4809        None
4810    };
4811
4812    Ok(ResolvedHealthCoverageInputs {
4813        coverage: cli_coverage
4814            .map(std::path::Path::to_path_buf)
4815            .or(env_coverage)
4816            .or_else(|| {
4817                config_health
4818                    .as_ref()
4819                    .and_then(|health| health.coverage.clone())
4820            }),
4821        coverage_root: cli_coverage_root
4822            .map(std::path::Path::to_path_buf)
4823            .or(env_coverage_root)
4824            .or_else(|| {
4825                config_health
4826                    .as_ref()
4827                    .and_then(|health| health.coverage_root.clone())
4828            }),
4829    })
4830}
4831
4832fn path_from_env(name: &str) -> Option<PathBuf> {
4833    std::env::var_os(name)
4834        .filter(|value| !value.is_empty())
4835        .map(PathBuf::from)
4836}
4837
4838fn validate_health_report_only_gate(
4839    report_only: bool,
4840    min_score: Option<f64>,
4841    min_severity: Option<fallow_output::FindingSeverity>,
4842    output: fallow_config::OutputFormat,
4843) -> Result<(), ExitCode> {
4844    if report_only && (min_score.is_some() || min_severity.is_some()) {
4845        return Err(emit_error(
4846            "--report-only cannot be combined with --min-score or --min-severity. \
4847             --report-only always exits 0; drop it to gate on score/severity, or \
4848             drop the gate flags to stay advisory.",
4849            2,
4850            output,
4851        ));
4852    }
4853
4854    Ok(())
4855}
4856
4857fn resolve_runtime_coverage_options(
4858    runtime_coverage: Option<&std::path::Path>,
4859    min_invocations_hot: u64,
4860    min_observation_volume: Option<u32>,
4861    low_traffic_threshold: Option<f64>,
4862    output: fallow_config::OutputFormat,
4863) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4864    let Some(path) = runtime_coverage else {
4865        return Ok(None);
4866    };
4867
4868    health::coverage::prepare_options(
4869        path,
4870        min_invocations_hot,
4871        min_observation_volume,
4872        low_traffic_threshold,
4873        output,
4874    )
4875    .map(Some)
4876}
4877
4878fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4879    let cli = dispatch.cli;
4880    let root = dispatch.root;
4881    let (output, _quiet, _fail_on_issues) =
4882        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4883    if let Err(code) = validate_health_report_only_gate(
4884        args.report_only,
4885        args.min_score,
4886        args.min_severity,
4887        output,
4888    ) {
4889        return code;
4890    }
4891    let runtime_coverage = match resolve_runtime_coverage_options(
4892        args.runtime_coverage,
4893        args.min_invocations_hot,
4894        args.min_observation_volume,
4895        args.low_traffic_threshold,
4896        output,
4897    ) {
4898        Ok(options) => options,
4899        Err(code) => return code,
4900    };
4901    let production = match resolve_production_modes(cli, root, output, false, false, false) {
4902        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4903        Err(code) => return code,
4904    };
4905    let coverage_inputs =
4906        match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4907            Ok(inputs) => inputs,
4908            Err(code) => return code,
4909        };
4910    let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4911    run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4912}
4913
4914fn derive_health_dispatch_run<'a>(
4915    args: &'a HealthDispatchArgs<'a>,
4916    output: fallow_config::OutputFormat,
4917    coverage_inputs: &'a ResolvedHealthCoverageInputs,
4918    runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4919) -> fallow_engine::health::HealthRunOptions<'a> {
4920    fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4921        output,
4922        thresholds: health_threshold_overrides(args),
4923        top: args.top,
4924        sort: args.sort.clone().into(),
4925        complexity: args.complexity,
4926        file_scores: args.file_scores,
4927        coverage_gaps: args.coverage_gaps,
4928        hotspots: args.hotspots,
4929        ownership: args.ownership,
4930        ownership_emails: args.ownership_emails,
4931        targets: args.targets,
4932        css: args.css,
4933        effort: args.effort.map(EffortFilter::to_estimate),
4934        score: args.score,
4935        gates: health_gate_options(args),
4936        snapshot_requested: args.save_snapshot.is_some(),
4937        trend: args.trend,
4938        since: args.since,
4939        min_commits: args.min_commits,
4940        coverage_inputs: health_coverage_inputs(coverage_inputs),
4941        runtime_coverage,
4942    })
4943}
4944
4945fn health_threshold_overrides(
4946    args: &HealthDispatchArgs<'_>,
4947) -> fallow_engine::health::HealthThresholdOverrides {
4948    fallow_engine::health::HealthThresholdOverrides {
4949        max_cyclomatic: args.max_cyclomatic,
4950        max_cognitive: args.max_cognitive,
4951        max_crap: args.max_crap,
4952    }
4953}
4954
4955fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
4956    fallow_engine::health::HealthGateOptions {
4957        min_score: args.min_score,
4958        min_severity: args.min_severity,
4959        report_only: args.report_only,
4960    }
4961}
4962
4963fn health_coverage_inputs(
4964    coverage_inputs: &ResolvedHealthCoverageInputs,
4965) -> fallow_engine::health::HealthCoverageInputs<'_> {
4966    fallow_engine::health::HealthCoverageInputs {
4967        coverage: coverage_inputs.coverage.as_deref(),
4968        coverage_root: coverage_inputs.coverage_root.as_deref(),
4969    }
4970}
4971
4972/// Resolved inputs threaded from `dispatch_health` into the `HealthOptions`
4973/// builder. Owns the normalized engine run contract and resolved production
4974/// mode.
4975struct ResolvedHealthDispatch<'a> {
4976    run: fallow_engine::health::HealthRunOptions<'a>,
4977    production: bool,
4978}
4979
4980/// Build `HealthOptions` from the parsed args plus the resolved dispatch inputs,
4981/// then run the health analysis.
4982fn run_health_dispatch(
4983    dispatch: &DispatchContext<'_>,
4984    args: &HealthDispatchArgs<'_>,
4985    resolved: ResolvedHealthDispatch<'_>,
4986) -> ExitCode {
4987    let cli = dispatch.cli;
4988    let (output, quiet, _fail_on_issues) =
4989        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4990    let run = resolved.run;
4991    let sections = run.sections;
4992    let production = resolved.production;
4993    health::run_health(
4994        &HealthOptions {
4995            root: dispatch.root,
4996            config_path: &cli.config,
4997            output,
4998            no_cache: cli.no_cache,
4999            threads: dispatch.threads,
5000            quiet,
5001            thresholds: run.thresholds,
5002            top: run.top,
5003            sort: run.sort,
5004            production,
5005            production_override: Some(production),
5006            allow_remote_extends: cli.allow_remote_extends,
5007            changed_since: cli.changed_since.as_deref(),
5008            diff_index: None,
5009            use_shared_diff_index: true,
5010            workspace: cli.workspace.as_deref(),
5011            changed_workspaces: cli.changed_workspaces.as_deref(),
5012            baseline: cli.baseline.as_deref(),
5013            save_baseline: cli.save_baseline.as_deref(),
5014            complexity: sections.complexity,
5015            file_scores: sections.file_scores,
5016            coverage_gaps: sections.coverage_gaps,
5017            config_activates_coverage_gaps: !sections.any_section,
5018            hotspots: sections.hotspots,
5019            ownership: run.ownership,
5020            ownership_emails: run.ownership_emails,
5021            targets: sections.targets,
5022            css: sections.css,
5023            css_deep: false,
5024            force_full: sections.force_full,
5025            score_only_output: sections.score_only_output,
5026            enforce_coverage_gap_gate: true,
5027            effort: run.effort,
5028            score: sections.score,
5029            gates: run.gates,
5030            since: run.since,
5031            min_commits: run.min_commits,
5032            explain: cli.explain,
5033            summary: cli.summary,
5034            save_snapshot: args
5035                .save_snapshot
5036                .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5037            trend: args.trend,
5038            coverage_inputs: run.coverage_inputs,
5039            performance: cli.performance,
5040            runtime_coverage: run.runtime_coverage,
5041            churn_file: cli.churn_file.as_deref(),
5042            complexity_breakdown: args.complexity_breakdown,
5043            group_by: cli.group_by.map(Into::into),
5044        },
5045        dispatch.json_style,
5046    )
5047}
5048
5049#[cfg(test)]
5050mod tests {
5051    use super::*;
5052
5053    /// Validates that the CLI definition has no flag name collisions, missing
5054    /// fields, or other structural errors. Catches issues like a global alias
5055    /// `--base` colliding with a subcommand's `--base` flag.
5056    #[test]
5057    fn cli_definition_has_no_flag_collisions() {
5058        use clap::CommandFactory;
5059        Cli::command().debug_assert();
5060    }
5061
5062    #[test]
5063    fn regression_baseline_help_explains_the_default_destination() {
5064        use clap::CommandFactory;
5065        let help = Cli::command().render_long_help().to_string();
5066
5067        assert!(help.contains("Omit PATH to update regression.baseline"));
5068        assert!(help.contains("discovered fallow config"));
5069        assert!(help.contains("create .fallowrc.json when none exists"));
5070    }
5071
5072    /// The root `--help` cheat sheet is a static const that cannot call the
5073    /// shared renderer, so this test is the only guard that it stays in sync
5074    /// with `TASK_MATRIX`. Every row's command string must appear verbatim.
5075    #[test]
5076    fn after_help_lists_every_task_matrix_command() {
5077        for row in crate::task_matrix::TASK_MATRIX {
5078            assert!(
5079                TOP_LEVEL_AFTER_HELP.contains(row.command),
5080                "root --help cheat sheet is missing task-matrix command '{}'; \
5081                 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5082                row.command
5083            );
5084        }
5085    }
5086
5087    /// The high-value and coarse admin commands each get a distinct telemetry
5088    /// workflow instead of the `Unknown` catch-all, so command families stay
5089    /// answerable without uploading raw command lines.
5090    #[test]
5091    fn high_value_commands_route_to_distinct_workflows() {
5092        use clap::Parser;
5093        use fallow_config::OutputFormat;
5094
5095        let distinct = [
5096            (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5097            (vec!["fallow", "security"], telemetry::Workflow::Security),
5098            (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5099            (
5100                vec!["fallow", "explain", "unused-exports"],
5101                telemetry::Workflow::Explain,
5102            ),
5103            (
5104                vec!["fallow", "watch"],
5105                telemetry::Workflow::CodeQualityReview,
5106            ),
5107            (
5108                vec!["fallow", "list"],
5109                telemetry::Workflow::ProjectInventory,
5110            ),
5111            (
5112                vec!["fallow", "workspaces"],
5113                telemetry::Workflow::ProjectInventory,
5114            ),
5115            (
5116                vec!["fallow", "schema"],
5117                telemetry::Workflow::ProjectInventory,
5118            ),
5119            (vec!["fallow", "init"], telemetry::Workflow::Setup),
5120            (
5121                vec!["fallow", "hooks", "install", "--target", "git"],
5122                telemetry::Workflow::Setup,
5123            ),
5124            (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5125            (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5126            (
5127                vec!["fallow", "rule-pack-schema"],
5128                telemetry::Workflow::Setup,
5129            ),
5130            (vec!["fallow", "config"], telemetry::Workflow::Setup),
5131            (
5132                vec!["fallow", "ci-template", "gitlab"],
5133                telemetry::Workflow::Setup,
5134            ),
5135            (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5136            (
5137                vec!["fallow", "telemetry", "status"],
5138                telemetry::Workflow::Setup,
5139            ),
5140            (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5141            (
5142                vec!["fallow", "audit-cache", "remove", "--root", "."],
5143                telemetry::Workflow::Setup,
5144            ),
5145            (
5146                vec!["fallow", "license", "status"],
5147                telemetry::Workflow::License,
5148            ),
5149        ];
5150        for (argv, expected) in distinct {
5151            let cli = Cli::try_parse_from(&argv).expect("argv parses");
5152            assert_eq!(
5153                telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5154                expected,
5155                "{argv:?} should map to {expected:?}"
5156            );
5157        }
5158    }
5159
5160    /// `-v`, `-V`, and `--version` must all trigger clap's Version action so
5161    /// the version prints regardless of which spelling the user reaches for
5162    /// (issue #916). clap surfaces a Version action from `try_get_matches_from`
5163    /// as the `DisplayVersion` error kind.
5164    #[test]
5165    fn version_flag_accepts_lower_v_upper_v_and_long() {
5166        use clap::CommandFactory;
5167        for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5168            let err = Cli::command()
5169                .try_get_matches_from(argv)
5170                .expect_err("version flag should short-circuit parsing");
5171            assert_eq!(
5172                err.kind(),
5173                clap::error::ErrorKind::DisplayVersion,
5174                "{argv:?} should trigger the Version action"
5175            );
5176        }
5177    }
5178
5179    /// Guard against deferred-work wording leaking into clap-rendered help.
5180    /// `stub`, `placeholder`, and `not yet` framings tell users the feature
5181    /// is broken or pending; they belong in tracked issues, not in `--help`.
5182    /// Walk every (sub)command and assert each rendered long-help is clean.
5183    #[test]
5184    fn cli_help_text_contains_no_implementation_status_wording() {
5185        use clap::CommandFactory;
5186        let mut root = Cli::command();
5187        let mut violations: Vec<(String, String)> = Vec::new();
5188        visit_help(&mut root, "fallow", &mut violations);
5189        assert!(
5190            violations.is_empty(),
5191            "found implementation-status wording in --help output:\n{}",
5192            violations
5193                .iter()
5194                .map(|(cmd, line)| format!("  {cmd}: {line}"))
5195                .collect::<Vec<_>>()
5196                .join("\n")
5197        );
5198    }
5199
5200    #[test]
5201    fn top_level_help_groups_commands_by_workflow() {
5202        use clap::CommandFactory;
5203        let help = Cli::command().render_long_help().to_string();
5204        let expected_order = [
5205            "Analysis:",
5206            "  dead-code",
5207            "  dupes",
5208            "  health",
5209            "  flags",
5210            "  security",
5211            "  audit",
5212            "Workflow:",
5213            "  watch",
5214            "  fix",
5215            "Project inspection:",
5216            "  list",
5217            "  workspaces",
5218            "  explain",
5219            "  impact",
5220            "Setup and configuration:",
5221            "  init",
5222            "  recommend",
5223            "  migrate",
5224            "  config",
5225            "  config-schema",
5226            "  plugin-schema",
5227            "  plugin-check",
5228            "  rule-pack-schema",
5229            "Automation and CI:",
5230            "  ci",
5231            "  ci-template",
5232            "  hooks",
5233            "  setup-hooks",
5234            "Runtime coverage:",
5235            "  coverage",
5236            "  license",
5237            "Reference:",
5238            "  schema",
5239            "  help",
5240            "Options:",
5241        ];
5242        let mut cursor = 0;
5243        for needle in expected_order {
5244            let Some(offset) = help[cursor..].find(needle) else {
5245                panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5246            };
5247            cursor += offset + needle.len();
5248        }
5249    }
5250
5251    #[test]
5252    fn security_help_hides_globals_rejected_by_security_validator() {
5253        let help = render_security_help(SecurityHelpTarget::Parent);
5254
5255        for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5256            assert!(
5257                !help_contains_long_flag(&help, long),
5258                "security help must hide unsupported --{long}:\n{help}"
5259            );
5260        }
5261
5262        for long in [
5263            "root",
5264            "config",
5265            "format",
5266            "quiet",
5267            "no-cache",
5268            "threads",
5269            "changed-since",
5270            "diff-file",
5271            "diff-stdin",
5272            "workspace",
5273            "changed-workspaces",
5274            "ci",
5275            "fail-on-issues",
5276            "sarif-file",
5277            "summary",
5278            "output-file",
5279            "max-file-size",
5280            "explain",
5281            "surface",
5282        ] {
5283            assert!(
5284                help_contains_long_flag(&help, long),
5285                "security help must keep supported --{long}:\n{help}"
5286            );
5287        }
5288    }
5289
5290    #[test]
5291    fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5292        assert_eq!(
5293            security_help_target(["security", "--help"]),
5294            Some(SecurityHelpTarget::Parent)
5295        );
5296        assert_eq!(
5297            security_help_target(["security", "-h"]),
5298            Some(SecurityHelpTarget::Parent)
5299        );
5300        assert_eq!(
5301            security_help_target(["--format", "json", "security", "--help"]),
5302            Some(SecurityHelpTarget::Parent)
5303        );
5304        assert_eq!(
5305            security_help_target(["help", "security"]),
5306            Some(SecurityHelpTarget::Parent)
5307        );
5308        assert_eq!(
5309            security_help_target(["security", "survivors", "--help"]),
5310            Some(SecurityHelpTarget::Survivors)
5311        );
5312        assert_eq!(
5313            security_help_target(["security", "survivors", "-h"]),
5314            Some(SecurityHelpTarget::Survivors)
5315        );
5316        assert_eq!(
5317            security_help_target(["help", "security", "survivors"]),
5318            Some(SecurityHelpTarget::Survivors)
5319        );
5320        assert_eq!(
5321            security_help_target(["security", "blind-spots", "--help"]),
5322            Some(SecurityHelpTarget::BlindSpots)
5323        );
5324        assert_eq!(
5325            security_help_target(["help", "security", "blind-spots"]),
5326            Some(SecurityHelpTarget::BlindSpots)
5327        );
5328        assert_eq!(security_help_target(["health", "--help"]), None);
5329        assert_eq!(security_help_target(["help", "health"]), None);
5330    }
5331
5332    #[test]
5333    fn security_unsupported_global_validator_matches_hidden_help_contract() {
5334        for (argv, expected) in [
5335            (vec!["fallow", "security", "--performance"], "--performance"),
5336            (
5337                vec!["fallow", "security", "--baseline", "base.json"],
5338                "--baseline",
5339            ),
5340            (
5341                vec!["fallow", "security", "--dupes-mode", "weak"],
5342                "--dupes-mode",
5343            ),
5344        ] {
5345            let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5346            assert_eq!(unsupported_security_global(&cli), Some(expected));
5347        }
5348
5349        let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5350            .expect("security --explain parses");
5351        assert_eq!(unsupported_security_global(&explain), None);
5352    }
5353
5354    #[test]
5355    fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5356        use clap::CommandFactory;
5357
5358        let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5359            .get_arguments()
5360            .filter(|arg| arg.is_global_set())
5361            .filter_map(|arg| arg.get_long().map(str::to_owned))
5362            .filter(|name| {
5363                matches!(
5364                    name.as_str(),
5365                    "root"
5366                        | "config"
5367                        | "allow-remote-extends"
5368                        | "no-cache"
5369                        | "threads"
5370                        | "changed-since"
5371                        | "diff-file"
5372                        | "production"
5373                        | "workspace"
5374                        | "changed-workspaces"
5375                        | "explain"
5376                )
5377            })
5378            .collect();
5379        let programmatic_flags: std::collections::BTreeSet<String> =
5380            fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5381                .iter()
5382                .map(|flag| (*flag).to_owned())
5383                .collect();
5384
5385        assert_eq!(programmatic_flags, cli_flags);
5386    }
5387
5388    #[test]
5389    fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5390        use clap::CommandFactory;
5391
5392        let cli = Cli::command();
5393        let dead_code = cli
5394            .get_subcommands()
5395            .find(|command| command.get_name() == "dead-code")
5396            .expect("dead-code subcommand is registered");
5397        let cli_flags: std::collections::BTreeSet<String> = dead_code
5398            .get_arguments()
5399            .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5400            .collect();
5401
5402        for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5403            assert!(
5404                cli_flags.contains(*flag),
5405                "registry filter flag {flag} is missing from dead-code clap args"
5406            );
5407        }
5408    }
5409
5410    fn help_contains_long_flag(help: &str, long: &str) -> bool {
5411        let flag = format!("--{long}");
5412        help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5413            .any(|token| token == flag)
5414    }
5415
5416    fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5417        let help = cmd.render_long_help().to_string();
5418        for line in scan_forbidden(&help) {
5419            violations.push((path.to_owned(), line));
5420        }
5421        let names: Vec<String> = cmd
5422            .get_subcommands()
5423            .map(|sub| sub.get_name().to_owned())
5424            .collect();
5425        for name in names {
5426            if name == "help" {
5427                continue;
5428            }
5429            if let Some(sub) = cmd.find_subcommand_mut(&name) {
5430                let sub_path = format!("{path} {name}");
5431                visit_help(sub, &sub_path, violations);
5432            }
5433        }
5434    }
5435
5436    fn scan_forbidden(s: &str) -> Vec<String> {
5437        let lower = s.to_ascii_lowercase();
5438        let mut out = Vec::new();
5439        for word in ["stub", "placeholder"] {
5440            if let Some(idx) = find_whole_word(&lower, word) {
5441                out.push(extract_line(s, idx));
5442            }
5443        }
5444        if let Some(idx) = lower.find("not yet") {
5445            out.push(extract_line(s, idx));
5446        }
5447        out
5448    }
5449
5450    fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5451        let bytes = haystack.as_bytes();
5452        let mut start = 0;
5453        while let Some(rel) = haystack[start..].find(word) {
5454            let abs = start + rel;
5455            let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5456            let after_idx = abs + word.len();
5457            let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5458            if before_ok && after_ok {
5459                return Some(abs);
5460            }
5461            start = abs + word.len();
5462        }
5463        None
5464    }
5465
5466    fn extract_line(s: &str, byte_idx: usize) -> String {
5467        let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5468        let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5469        s[line_start..line_end].trim().to_owned()
5470    }
5471
5472    #[test]
5473    fn emit_error_returns_given_exit_code() {
5474        let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5475        assert_eq!(code, ExitCode::from(2));
5476    }
5477
5478    fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5479        TelemetryRun {
5480            workflow: telemetry::Workflow::Health,
5481            output: fallow_config::OutputFormat::Json,
5482            quiet: true,
5483            start: std::time::Instant::now(),
5484            context: telemetry::WorkflowContext {
5485                run_scope: telemetry::RunScope::FullProject,
5486                config_shape: telemetry::ConfigShape::Default,
5487                output_destination: telemetry::OutputDestination::Stdout,
5488                analysis_mode: mode,
5489            },
5490        }
5491    }
5492
5493    #[test]
5494    fn fallback_failure_reason_skips_success_and_findings() {
5495        let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5496
5497        assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5498        assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5499    }
5500
5501    #[test]
5502    fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5503        let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5504        let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5505
5506        assert_eq!(
5507            fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5508            Some(telemetry::FailureReason::Network),
5509        );
5510        assert_eq!(
5511            fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5512            Some(telemetry::FailureReason::Auth),
5513        );
5514        assert_eq!(
5515            fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5516            Some(telemetry::FailureReason::Auth),
5517        );
5518        assert_eq!(
5519            fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5520            Some(telemetry::FailureReason::Analysis),
5521        );
5522    }
5523
5524    #[test]
5525    fn bare_coverage_flags_parse_without_subcommand() {
5526        let cli = Cli::try_parse_from([
5527            "fallow",
5528            "--coverage",
5529            "coverage/coverage-final.json",
5530            "--coverage-root",
5531            "/ci/workspace",
5532        ])
5533        .expect("bare combined coverage flags should parse");
5534        assert!(cli.command.is_none());
5535        assert_eq!(
5536            cli.coverage.as_deref(),
5537            Some(std::path::Path::new("coverage/coverage-final.json"))
5538        );
5539        assert_eq!(
5540            cli.coverage_root.as_deref(),
5541            Some(std::path::Path::new("/ci/workspace"))
5542        );
5543    }
5544
5545    #[test]
5546    fn bare_coverage_before_subcommand_is_detectable() {
5547        let cli = Cli::try_parse_from([
5548            "fallow",
5549            "--coverage",
5550            "coverage/coverage-final.json",
5551            "dead-code",
5552        ])
5553        .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5554        assert!(cli.command.is_some());
5555        assert!(cli_has_bare_coverage_input(&cli));
5556        let message = bare_coverage_subcommand_error_message();
5557        assert!(message.contains("bare combined-mode flags"));
5558        assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5559    }
5560
5561    #[test]
5562    fn subcommand_coverage_flag_keeps_regular_clap_error() {
5563        let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5564            panic!("dead-code --coverage should fail to parse");
5565        };
5566        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5567    }
5568
5569    #[test]
5570    fn format_parsing_covers_all_variants() {
5571        assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5572        assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5573        assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5574        assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5575        assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5576        assert!(matches!(
5577            parse_format_arg("markdown"),
5578            Some(Format::Markdown)
5579        ));
5580        assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5581        assert!(matches!(
5582            parse_format_arg("codeclimate"),
5583            Some(Format::CodeClimate)
5584        ));
5585        assert!(matches!(
5586            parse_format_arg("gitlab-codequality"),
5587            Some(Format::CodeClimate)
5588        ));
5589        assert!(matches!(
5590            parse_format_arg("gitlab-code-quality"),
5591            Some(Format::CodeClimate)
5592        ));
5593        assert!(matches!(
5594            parse_format_arg("pr-comment-github"),
5595            Some(Format::PrCommentGithub)
5596        ));
5597        assert!(matches!(
5598            parse_format_arg("pr-comment-gitlab"),
5599            Some(Format::PrCommentGitlab)
5600        ));
5601        assert!(matches!(
5602            parse_format_arg("review-github"),
5603            Some(Format::ReviewGithub)
5604        ));
5605        assert!(matches!(
5606            parse_format_arg("review-gitlab"),
5607            Some(Format::ReviewGitlab)
5608        ));
5609        assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5610        assert!(parse_format_arg("xml").is_none());
5611        assert!(parse_format_arg("").is_none());
5612    }
5613
5614    #[test]
5615    fn quiet_parsing_logic() {
5616        let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5617        assert!(parse("1"));
5618        assert!(parse("true"));
5619        assert!(parse("TRUE"));
5620        assert!(parse("True"));
5621        assert!(!parse("0"));
5622        assert!(!parse("false"));
5623        assert!(!parse("yes"));
5624    }
5625
5626    #[test]
5627    fn tracing_filter_defaults_to_warn_without_env() {
5628        assert_eq!(build_tracing_filter(None).to_string(), "warn");
5629    }
5630
5631    #[test]
5632    fn tracing_filter_respects_explicit_env_directives() {
5633        assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5634    }
5635
5636    #[test]
5637    fn tracing_filter_treats_empty_env_as_off() {
5638        assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5639        assert_eq!(build_tracing_filter(Some("   ")).to_string(), "off");
5640    }
5641}