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_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31mod baselines;
32/// Re-exported for integration tests so they hash reusable-cache roots through
33/// the exact production path (`dunce` canonicalization + platform path-identity
34/// bytes) rather than an approximation that diverges on Windows.
35pub use base_worktree::canonical_root_hash;
36mod walkthrough_state;
37use fallow_engine::baseline;
38mod agent_install;
39mod baseline_gate;
40mod baseline_growth;
41mod cache_notice;
42mod check;
43mod ci;
44mod ci_template;
45mod claude_code_hint;
46mod cli_agent;
47mod cli_format;
48mod cli_hooks;
49mod cli_impact;
50mod cli_production;
51mod cli_report;
52mod cli_startup;
53pub use fallow_engine::codeowners;
54mod combined;
55mod config;
56mod count_delta;
57mod coverage;
58mod discovery_note;
59mod doctor;
60mod dupes;
61mod exit_codes;
62pub mod explain;
63mod fix;
64mod flags;
65mod flags_retirement_formats;
66mod gates;
67mod guard;
68mod health;
69mod impact;
70mod init;
71mod inspect;
72mod json_style;
73mod license;
74mod list;
75mod migrate;
76mod onboarding;
77#[cfg(test)]
78mod output_envelope;
79mod output_runtime;
80mod path_util;
81mod plugin_check;
82mod process_clock;
83mod rayon_pool;
84mod regression;
85pub mod report;
86mod requests;
87mod rule_pack;
88mod runtime_support;
89mod schema;
90mod scope_path;
91mod security;
92mod security_help;
93mod selector;
94mod setup_hooks;
95mod signal;
96mod similar_code_cli;
97mod similar_code_help;
98mod suppressions;
99mod task_matrix;
100mod telemetry;
101mod trace_chain;
102mod trace_dependency;
103mod trace_error;
104mod trace_path;
105mod type_aware_degrade;
106mod update_check;
107use fallow_engine::validate;
108use fallow_engine::vital_signs;
109mod cli_telemetry;
110mod viz;
111mod watch;
112mod write_scope;
113
114use check::{CheckOptions, IssueFilters, TraceOptions};
115/// Structured error output for CLI and JSON formats.
116pub(crate) mod error;
117use cli_agent::{AgentCli, run_agent_command};
118#[cfg(test)]
119use cli_format::parse_format_arg;
120use cli_format::{Format, FormatConfig};
121use cli_hooks::{HooksCli, run_hooks_command};
122use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
123use cli_production::{ProductionModes, resolve_production_modes};
124#[cfg(test)]
125use cli_startup::build_tracing_filter;
126use cli_startup::{
127    bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
128    cli_bare_combined_baseline_flag, cli_global_baseline_flag, cli_has_bare_coverage_input,
129    parse_cli_args, reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing,
130    validate_inputs,
131};
132#[cfg(test)]
133use cli_telemetry::TelemetryRun;
134#[cfg(test)]
135use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
136use cli_telemetry::{record_run_epilogue, start_telemetry_run};
137use dupes::{DupesMode, DupesOptions, DupesOverrides};
138use error::emit_error;
139use health::{HealthOptions, SortBy};
140use list::ListOptions;
141pub(crate) use runtime_support::{AnalysisKind, GroupBy};
142pub(crate) use runtime_support::{
143    ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
144    load_config_for_analysis,
145};
146#[cfg(test)]
147use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
148use security_help::{render_security_help, security_help_target};
149use similar_code_help::{render_similar_code_help, similar_code_help_target};
150
151const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
152
153const TOP_LEVEL_HELP_TEMPLATE: &str =
154    "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
155
156// Macros instead of consts so `concat!` can assemble the short (`-h`) and
157// long (`--help`) after-help surfaces from the same source fragments.
158macro_rules! top_level_task_cheat_sheet {
159    () => {
160        "\
161When the agent is about to...
162  delete an \"unused\" export or file        fallow dead-code --trace <file>:<export>
163  prove exact TypeScript symbol consumers  fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
164  find how one module reaches another      fallow trace --path <from> <to>
165  delete an \"unused\" dependency            fallow dead-code --trace-dependency <name>
166  migrate a dependency                     fallow trace --dependency <name> --sites
167  commit or open a PR                      fallow audit --base <ref>
168  read a diff before approving it          fallow review --base <ref> --brief
169  prioritize refactoring                   fallow health --hotspots --targets
170  ask who owns code                        fallow health --ownership
171  check untested-but-reachable code        fallow health --coverage-gaps
172  consolidate duplication                  fallow dupes --trace dup:<fingerprint>
173  find feature flags                       fallow flags
174  check architecture rules before editing  fallow guard <files>
175  check cycles and boundaries after edits  fallow architecture
176  surface security candidates              fallow security
177  inspect a target before editing          fallow inspect --file <path>
178  understand a finding                     fallow explain <issue-type>
179  scope a monorepo                         --workspace <glob> / --changed-workspaces <ref>"
180    };
181}
182
183macro_rules! top_level_core_command_groups {
184    () => {
185        "\
186Analysis:
187  dead-code      Analyze unused code and dependency hygiene
188  guard          Show which architecture rules apply to files before editing
189  architecture   Check import cycles, boundaries and policy rules after editing
190  dupes          Find copy-paste and structural code duplication
191  health         Analyze complexity, maintainability, hotspots, and coverage gaps
192  flags          Detect feature flag usage patterns
193  security       Surface local security candidates for agent verification (opt-in)
194  similar-code   Find semantic implementation overlap for verification (opt-in, local)
195  audit          Review changed files for dead code, complexity, duplication, and styling
196
197Workflow:
198  watch          Re-run analysis as files change
199  fix            Auto-fix safe unused-code findings"
200    };
201}
202
203macro_rules! top_level_extended_command_groups {
204    () => {
205        "\
206Project inspection:
207  list              List discovered files, entry points, plugins, boundaries, workspaces, and entry weight
208  inspect           Inspect one file or exported symbol as a bundled evidence query
209  trace             Trace a symbol's call chain (best-effort, syntactic)
210  trace-error       Resolve a runtime stack trace's frames to project definitions
211  decision-surface  Surface the structural decisions a change embeds (advisory)
212  workspaces        Show monorepo workspace discovery diagnostics
213  explain           Explain one issue type without running analysis
214  suppressions      List active fallow-ignore suppression markers
215  impact            Show what fallow has done for you (opt-in, local-only)
216  viz               Generate an interactive HTML map of the codebase
217
218Setup and configuration:
219  doctor            Diagnose project readiness without changing anything
220  init              Create a fallow config, optionally with a Git hook
221  agent             Wire fallow into Claude Code, Codex, or Cursor in one pass
222  audit-cache       Maintain reusable audit base-snapshot caches
223  baselines         Prune committed baselines to the findings that remain
224  recommend         Recommend a project-tailored config for an agent to author
225  migrate           Migrate knip, jscpd, or stylelint config to fallow
226  config            Show the resolved config and loaded config file
227  config-schema     Print the fallow config JSON Schema
228  plugin-schema     Print the external plugin JSON Schema
229  plugin-check      Dry-run external plugins and report what they seed
230  rule-pack         Manage declarative rule packs (policy-as-code)
231  rule-pack-schema  Print the rule pack JSON Schema
232  type-aware        Inspect the optional TypeScript semantic companion
233
234Automation and CI:
235  ci             Build PR/MR feedback envelopes
236  ci-template    Print or vendor CI integration templates
237  report         Re-render saved JSON as GitHub or CodeClimate output
238  hooks          Install or remove fallow-managed Git and agent hooks
239  setup-hooks    Deprecated: use `agent install` or `hooks install --target agent`
240
241Runtime coverage:
242  coverage       Set up or analyze runtime coverage data
243  license        Manage the paid-feature license
244  telemetry      Manage opt-in product telemetry
245
246Reference:
247  schema         Dump the CLI interface as machine-readable JSON
248  help           Print this message or the help of a command"
249    };
250}
251
252const TOP_LEVEL_AFTER_HELP: &str = concat!(
253    top_level_task_cheat_sheet!(),
254    "\n\n",
255    top_level_core_command_groups!(),
256    "\n\nRun fallow --help for the complete command list."
257);
258
259const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
260    top_level_task_cheat_sheet!(),
261    "\n\n",
262    top_level_core_command_groups!(),
263    "\n\n",
264    top_level_extended_command_groups!(),
265    "\n\n",
266    "When no command is given, fallow runs dead-code + dupes + health together.\n",
267    "Use --only/--skip to select specific analyses."
268);
269
270#[derive(Parser)]
271#[command(
272    name = "fallow",
273    about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
274    version,
275    disable_version_flag = true,
276    help_template = TOP_LEVEL_HELP_TEMPLATE,
277    after_help = TOP_LEVEL_AFTER_HELP,
278    after_long_help = TOP_LEVEL_AFTER_LONG_HELP
279)]
280struct Cli {
281    #[command(subcommand)]
282    command: Option<Command>,
283
284    /// Scope reported findings to this file or directory (default: whole project).
285    /// The full project graph is still built; only reported items are narrowed.
286    #[arg(value_name = "PATH")]
287    path: Option<PathBuf>,
288
289    /// Print version.
290    /// Accepts `-v`, `-V`, and `--version`; TS/JS tooling (node, npm, pnpm,
291    /// yarn, bun, tsc) uses `-v`, while `-V` matches knip/oxlint/biome.
292    #[arg(
293        short = 'v',
294        visible_short_alias = 'V',
295        long = "version",
296        action = clap::ArgAction::Version
297    )]
298    version: Option<bool>,
299
300    /// Project root directory
301    #[arg(short, long, global = true)]
302    root: Option<PathBuf>,
303
304    /// Path to config file (.fallowrc.json, .fallowrc.jsonc, fallow.toml, or .fallow.toml)
305    #[arg(short, long, global = true)]
306    config: Option<PathBuf>,
307
308    /// Allow trusted config files to extend HTTPS URLs
309    #[arg(hide_short_help = true, long, global = true)]
310    allow_remote_extends: bool,
311
312    /// Output format (alias: --output)
313    #[arg(
314        short,
315        long,
316        visible_alias = "output",
317        global = true,
318        default_value = "human"
319    )]
320    format: Format,
321
322    /// Indent JSON output for manual inspection. Requires the final output format to be JSON.
323    #[arg(hide_short_help = true, long, global = true)]
324    pretty: bool,
325
326    /// Suppress progress output
327    #[arg(short, long, global = true)]
328    quiet: bool,
329
330    /// Disable incremental caching
331    #[arg(hide_short_help = true, long, global = true)]
332    no_cache: bool,
333
334    /// Number of parser threads
335    #[arg(hide_short_help = true, long, global = true)]
336    threads: Option<usize>,
337
338    /// Only report issues in files changed since this git ref (e.g., main, HEAD~5)
339    ///
340    /// For this run, the ref replaces the per-package refs of
341    /// `workspaces.changedSince` in the config.
342    #[arg(long, visible_alias = "base", global = true)]
343    changed_since: Option<String>,
344
345    /// Ignore the per-package refs of `workspaces.changedSince` for this run
346    ///
347    /// Every workspace package is then analyzed in full scope, for example to
348    /// save or gate a whole-project baseline. A global `--changed-since`,
349    /// `--workspace` or `--diff-file` scope still applies.
350    #[arg(hide_short_help = true, long, global = true)]
351    no_package_baselines: bool,
352
353    /// Unified diff for line-level scoping.
354    /// Use `-` to read from stdin. Project-level findings still bypass this
355    /// filter. When both this and `--changed-since` are set, the diff filter
356    /// wins for finding scope while `--changed-since` still drives file discovery.
357    #[arg(
358        hide_short_help = true,
359        long = "diff-file",
360        value_name = "PATH",
361        global = true
362    )]
363    diff_file: Option<PathBuf>,
364
365    /// Read the unified diff from stdin.
366    /// Equivalent to `--diff-file -`.
367    #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
368    diff_stdin: bool,
369
370    /// Import change history from a `fallow-churn/v1` JSON file instead of `git
371    /// log`, powering hotspots, ownership, and bus-factor on projects with no
372    /// git repository (Yandex Arc, Mercurial, Perforce). A small wrapper
373    /// translates your VCS log into the contract. Resolved relative to `--root`.
374    /// Affects `health --hotspots` / `--ownership` / `--targets` only; `audit`,
375    /// `impact`, and `--changed-since` still require git.
376    #[arg(
377        hide_short_help = true,
378        long = "churn-file",
379        value_name = "PATH",
380        global = true
381    )]
382    churn_file: Option<PathBuf>,
383
384    /// Skip source files larger than this many megabytes (default 5) instead of
385    /// parsing them, guarding against the out-of-memory blowup a single
386    /// multi-MB generated/vendored/bundled file causes on large repos. Use `0`
387    /// for no limit. Declaration files (`.d.ts`) are always analyzed. Skipped
388    /// files are reported and excluded from every analysis. Also settable via
389    /// `FALLOW_MAX_FILE_SIZE`.
390    #[arg(
391        hide_short_help = true,
392        long = "max-file-size",
393        value_name = "MB",
394        global = true
395    )]
396    max_file_size: Option<u32>,
397
398    /// Compare against a previously saved baseline file. Used by bare
399    /// `fallow`, `dead-code`, `architecture`, `dupes` and `health`; other
400    /// subcommands reject it. On bare `fallow` it holds the dead-code baseline
401    /// only: pass health and duplication baselines with `--health-baseline`
402    /// and `--dupes-baseline`
403    #[arg(hide_short_help = true, long, global = true)]
404    baseline: Option<PathBuf>,
405
406    /// How `--baseline` matches health findings: per file and category
407    /// (`count`, the default) or per function identity (`identity`, strict,
408    /// and only against a baseline that was saved with `--baseline-mode
409    /// identity`; such a baseline still reads in count mode).
410    ///
411    /// A finding's identity is its file path plus its function name, so
412    /// renaming or moving a function that is still in the baseline reports it
413    /// as new. Re-save the baseline after that kind of refactor. Functions
414    /// that share a name in one file, and unnamed functions, share one
415    /// identity and can mask each other.
416    ///
417    /// Defaults to `count` when omitted. Saving without the flag refuses to
418    /// overwrite a baseline that carries identities; pass `--baseline-mode
419    /// count` explicitly to downgrade such a baseline on purpose.
420    #[arg(
421        hide_short_help = true,
422        long = "baseline-mode",
423        value_enum,
424        global = true
425    )]
426    baseline_mode: Option<BaselineModeArg>,
427
428    /// Correlate this run with a previous telemetry analysis run.
429    ///
430    /// Used only for opt-in telemetry follow-up measurement. The value is not
431    /// interpreted as a path, repository, package, or user identifier. Hidden
432    /// from `--help`; agents receive the correlation token from JSON output.
433    #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
434    parent_run: Option<String>,
435
436    /// Save the current results as a baseline file. Used by bare `fallow`,
437    /// `dead-code`, `architecture`, `dupes` and `health`; other subcommands
438    /// reject it. On bare `fallow` it saves the dead-code baseline only: save
439    /// the others with `fallow health --save-baseline` and `fallow dupes
440    /// --save-baseline`. The path must resolve inside the project root, its Git
441    /// work tree, the CI workspace or a temp directory
442    #[arg(hide_short_help = true, long, global = true)]
443    save_baseline: Option<PathBuf>,
444
445    /// Production mode: exclude test/story/dev files, only start/build scripts,
446    /// report type-only dependencies
447    #[arg(long, global = true)]
448    production: bool,
449
450    /// Force production mode OFF for every analysis, overriding a project
451    /// config's `production: true` (and `FALLOW_PRODUCTION`). Conflicts with
452    /// `--production`.
453    #[arg(
454        hide_short_help = true,
455        long = "no-production",
456        global = true,
457        conflicts_with = "production"
458    )]
459    no_production: bool,
460
461    /// Run dead-code analysis in production mode when using bare combined mode.
462    #[arg(hide_short_help = true, long = "production-dead-code")]
463    production_dead_code: bool,
464
465    /// Run health analysis in production mode when using bare combined mode.
466    #[arg(hide_short_help = true, long = "production-health")]
467    production_health: bool,
468
469    /// Run duplication analysis in production mode when using bare combined mode.
470    #[arg(hide_short_help = true, long = "production-dupes")]
471    production_dupes: bool,
472
473    /// Scope output to selected workspaces.
474    /// Accepts exact names, glob patterns, and `!`-prefixed negations.
475    /// Values can be comma-separated or repeated.
476    #[arg(short, long, global = true, value_delimiter = ',')]
477    workspace: Option<Vec<String>>,
478
479    /// Scope output to workspaces touched since the given git ref.
480    /// Git is required. Mutually exclusive with `--workspace`.
481    #[arg(long, global = true, value_name = "REF")]
482    changed_workspaces: Option<String>,
483
484    /// Group output by owner or by directory.
485    #[arg(hide_short_help = true, long, global = true)]
486    group_by: Option<GroupBy>,
487
488    /// Keep only the matching groups of a `--group-by` health run.
489    /// Accepts exact group keys, glob patterns, and `!`-prefixed negations.
490    /// Values can be comma-separated or repeated. Project-level sections are
491    /// not filtered. Supported by `fallow health` only.
492    #[arg(
493        hide_short_help = true,
494        long,
495        global = true,
496        value_delimiter = ',',
497        value_name = "KEY",
498        requires = "group_by"
499    )]
500    group: Option<Vec<String>>,
501
502    /// Show pipeline performance timing breakdown
503    #[arg(hide_short_help = true, long, global = true)]
504    performance: bool,
505
506    /// Include metric definitions and rule descriptions in output.
507    #[arg(hide_short_help = true, long, global = true)]
508    explain: bool,
509
510    /// Show per-pattern counts for skipped files: default duplicate ignores on dupes
511    /// and audit, built-in discovery ignores on check, dead-code, audit and the default run.
512    #[arg(hide_short_help = true, long, global = true)]
513    explain_skipped: bool,
514
515    /// Show only category counts without individual items
516    #[arg(hide_short_help = true, long, global = true)]
517    summary: bool,
518
519    /// CI mode: equivalent to --format sarif --fail-on-issues --quiet
520    #[arg(long, global = true)]
521    ci: bool,
522
523    /// Exit with code 1 if issues are found
524    #[arg(hide_short_help = true, long, global = true)]
525    fail_on_issues: bool,
526
527    /// Write SARIF output to a file (in addition to the primary --format output).
528    /// Used by bare `fallow`, `dead-code`, `architecture` and `security`. The
529    /// path must resolve inside the project root, its Git work tree, the CI
530    /// workspace or a temp directory
531    #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
532    sarif_file: Option<PathBuf>,
533
534    /// Write the report to a file instead of stdout, for any --format (no ANSI
535    /// codes). Useful on large projects where the terminal scrollback truncates
536    /// the top. Progress and the confirmation stay on stderr. The path must
537    /// resolve inside the project root, its Git work tree, the CI workspace or
538    /// a temp directory.
539    #[arg(short = 'o', long, global = true, value_name = "PATH")]
540    output_file: Option<PathBuf>,
541
542    /// Prefix prepended to every path in the CI-facing formats
543    /// (`github-annotations`, `github-summary`, `codeclimate`,
544    /// `pr-comment-github`, `pr-comment-gitlab`, `review-github`,
545    /// `review-gitlab`). CI platforms address files by
546    /// repository-root-relative path, so when the analyzed project lives in a
547    /// subdirectory (e.g. `packages/app/`), paths need that offset. fallow
548    /// detects the offset via the git toplevel automatically; this flag
549    /// overrides the detection. Pass an empty string to disable rebasing and
550    /// emit paths relative to `--root`.
551    #[arg(
552        hide_short_help = true,
553        long = "report-path-prefix",
554        visible_alias = "annotations-path-prefix",
555        global = true,
556        value_name = "PREFIX"
557    )]
558    report_path_prefix: Option<String>,
559
560    /// Fail if issue count increased beyond tolerance compared to a regression baseline.
561    ///
562    /// With `list --entry-weight`, fail when the eager source bytes of an
563    /// entry grew beyond the tolerance. Without this flag that comparison is
564    /// report-only.
565    #[arg(hide_short_help = true, long, global = true)]
566    fail_on_regression: bool,
567
568    /// Exit with code 1 if a loaded --baseline has entries that match nothing in this run.
569    ///
570    /// Stricter than the default advisory warning: any stale entry fails, not
571    /// just a quarter of the baseline. Applies in every output format. The
572    /// verdict reaches a machine consumer as `gate_outcomes["stale-baseline"]`,
573    /// which is published with or without this flag; the flag decides only
574    /// whether that entry is `enforced`, and the exit code plus one stderr
575    /// line. Inert when no baseline is loaded, on runs narrowed to part of
576    /// the project (including production mode), which cannot judge a
577    /// whole-project baseline, and under `health --report-only`, which never
578    /// fails a run; such a run prints why it stood down instead of passing
579    /// quietly. `fallow audit` always analyzes only changed files, so the gate
580    /// never fires there.
581    #[arg(hide_short_help = true, long, global = true)]
582    fail_on_stale_baseline: bool,
583
584    /// Exit with code 1 if a loaded baseline has a key that the same file at the base ref does not have.
585    ///
586    /// Makes a committed baseline shrink-only. A change that adds a finding and
587    /// re-saves the baseline in the same commit passes `--baseline` and
588    /// `--fail-on-stale-baseline`; this gate fails it and lists each new key
589    /// per category on stderr. A renamed file or a moved line gives a new key,
590    /// so it counts as growth. The base ref is `--baseline-base`, else the
591    /// `fallow audit` base: `--changed-since` / `--base`, then
592    /// `FALLOW_AUDIT_BASE`, then the merge-base with the upstream or the remote
593    /// default branch. A baseline that the base ref does not have is a new
594    /// baseline and passes with a note. A base ref that git cannot resolve
595    /// (for example in a shallow clone) exits 2. Applies to `dead-code`,
596    /// `architecture`, `dupes`, `health`, `audit` and the bare run, and reaches a machine
597    /// consumer as `gate_outcomes["baseline-growth"]`.
598    #[arg(hide_short_help = true, long, global = true)]
599    fail_on_baseline_growth: bool,
600
601    /// The git ref that --fail-on-baseline-growth compares the baseline with.
602    ///
603    /// Defaults to the `fallow audit` base resolution. Needs
604    /// --fail-on-baseline-growth.
605    #[arg(hide_short_help = true, long, global = true, value_name = "REF")]
606    baseline_base: Option<String>,
607
608    /// Exit with code 1 if fallow could not parse a source file cleanly.
609    ///
610    /// Arms the `parse-error` gate: a file with a `source-parse-degraded`
611    /// entry in `workspace_diagnostics[]` fails the run, and the gate entry in
612    /// `gate_outcomes["parse-error"]` names each such file. Off by default,
613    /// because the parser also rejects valid syntax that is newer than the
614    /// parser. Applies to `dead-code`, `architecture`, `health`, `audit` and
615    /// the bare run, in every output format. The `failOnParseError` config key
616    /// arms the same gate. `health --report-only` never fails a run.
617    #[arg(hide_short_help = true, long, global = true)]
618    fail_on_parse_error: bool,
619
620    /// Allowed issue count increase before a regression is flagged.
621    ///
622    /// With `list --entry-weight`, the value is the allowed growth of eager
623    /// source bytes per entry: a byte count such as `1024`, or a percentage
624    /// such as `5%`.
625    #[arg(
626        hide_short_help = true,
627        long,
628        global = true,
629        value_name = "TOLERANCE",
630        default_value = "0"
631    )]
632    tolerance: String,
633
634    /// Path to the regression baseline file.
635    #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
636    regression_baseline: Option<PathBuf>,
637
638    /// Save the current issue counts as a regression baseline. Omit PATH to
639    /// update regression.baseline in the discovered fallow config, or create
640    /// .fallowrc.json when none exists. Provide PATH to write a standalone file;
641    /// PATH must resolve inside the project root, its Git work tree, the CI workspace or a temp directory.
642    #[expect(
643        clippy::option_option,
644        reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
645    )]
646    #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
647    save_regression_baseline: Option<Option<String>>,
648
649    /// Run only specific analyses when no subcommand is given.
650    #[arg(long, value_delimiter = ',')]
651    only: Vec<AnalysisKind>,
652
653    /// Skip specific analyses when no subcommand is given.
654    #[arg(long, value_delimiter = ',')]
655    skip: Vec<AnalysisKind>,
656
657    /// Override duplication detection mode in combined mode.
658    #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
659    dupes_mode: Option<DupesMode>,
660
661    /// Enable function-scoped near-miss clone detection in combined mode.
662    #[arg(hide_short_help = true, long = "dupes-near", global = true)]
663    dupes_near: bool,
664
665    /// Override duplication threshold in combined mode.
666    #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
667    dupes_threshold: Option<f64>,
668
669    /// Override the minimum token count for clones in combined mode.
670    #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
671    dupes_min_tokens: Option<usize>,
672
673    /// Override the minimum line count for clones in combined mode.
674    #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
675    dupes_min_lines: Option<usize>,
676
677    /// Override the minimum clone occurrences in combined mode (must be >= 2).
678    #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
679    dupes_min_occurrences: Option<usize>,
680
681    /// Only report cross-directory duplicates in combined mode.
682    #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
683    dupes_skip_local: bool,
684
685    /// Enable cross-language duplicate detection in combined mode.
686    #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
687    dupes_cross_language: bool,
688
689    /// Exclude module wiring from duplicate detection in combined mode
690    /// (default). Pass `--dupes-no-ignore-imports` to count it again.
691    #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
692    dupes_ignore_imports: bool,
693
694    /// Count module wiring as clone candidates in combined mode (opt out of the
695    /// default exclusion).
696    #[arg(
697        hide_short_help = true,
698        long = "dupes-no-ignore-imports",
699        global = true,
700        conflicts_with = "dupes_ignore_imports"
701    )]
702    dupes_no_ignore_imports: bool,
703
704    /// Omit clone instances whose path is a symlink, or lies under a
705    /// symlinked directory, in combined mode.
706    #[arg(hide_short_help = true, long = "dupes-ignore-symlinks", global = true)]
707    dupes_ignore_symlinks: bool,
708
709    /// Report symlinked clone instances in combined mode (opt out of a config
710    /// `duplicates.ignoreSymlinks: true`).
711    #[arg(
712        hide_short_help = true,
713        long = "dupes-no-ignore-symlinks",
714        global = true,
715        conflicts_with = "dupes_ignore_symlinks"
716    )]
717    dupes_no_ignore_symlinks: bool,
718
719    /// Compute health score in combined mode.
720    #[arg(hide_short_help = true, long)]
721    score: bool,
722
723    /// Compare current health metrics against the most recent saved snapshot.
724    #[arg(hide_short_help = true, long)]
725    trend: bool,
726
727    /// Compare current health metrics against this snapshot file in combined
728    /// mode. Implies --trend and --score.
729    #[arg(hide_short_help = true, long, value_name = "PATH")]
730    trend_from: Option<PathBuf>,
731
732    /// Save a vital signs snapshot for trend tracking in combined mode.
733    /// Provide a path or omit for the default `.fallow/snapshots/` location.
734    /// A given path must resolve inside the project root, its Git work tree, the CI workspace or a temp directory.
735    #[expect(
736        clippy::option_option,
737        reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
738    )]
739    #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
740    save_snapshot: Option<Option<String>>,
741
742    /// Path to Istanbul or raw V8 coverage data for exact CRAP scores in combined mode.
743    /// Also settable via `FALLOW_COVERAGE` or `health.coverage`.
744    #[arg(hide_short_help = true, long, value_name = "PATH")]
745    coverage: Option<PathBuf>,
746
747    /// Absolute prefix to strip from Istanbul file paths in combined mode.
748    /// Also settable via `FALLOW_COVERAGE_ROOT` or `health.coverageRoot`.
749    #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
750    coverage_root: Option<PathBuf>,
751
752    /// Compare duplication clone groups against a saved baseline in combined
753    /// mode (produced by `fallow dupes --save-baseline`).
754    #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
755    dupes_baseline: Option<PathBuf>,
756
757    /// Compare health findings against a saved baseline in combined mode
758    /// (produced by `fallow health --save-baseline`).
759    #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
760    health_baseline: Option<PathBuf>,
761
762    /// Report unused exports in entry files instead of auto-marking them as used.
763    #[arg(hide_short_help = true, long, global = true)]
764    include_entry_exports: bool,
765
766    /// Also report the unused exports, types, class members and enum members
767    /// of unused files. By default the report hides them, because deleting the
768    /// file removes them, and counts them in `cascade_hidden`.
769    #[arg(hide_short_help = true, long, global = true)]
770    show_cascade: bool,
771
772    /// Opt in to TypeScript semantic analysis for project-wide symbol evidence.
773    /// This does not emit compiler diagnostics or typed lint findings.
774    #[arg(hide_short_help = true, long, global = true)]
775    type_aware: bool,
776
777    /// Disable TypeScript semantic analysis even when `typeAware.enabled` or
778    /// `FALLOW_TYPE_AWARE` opts in, keeping this run fully syntactic.
779    #[arg(
780        hide_short_help = true,
781        long,
782        global = true,
783        conflicts_with = "type_aware"
784    )]
785    no_type_aware: bool,
786
787    /// TypeScript project config to use for type-aware analysis (repeatable).
788    #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
789    type_aware_project: Vec<PathBuf>,
790
791    /// Decide whether incomplete type-aware analysis is advisory or gating.
792    #[arg(hide_short_help = true, long, global = true, value_enum)]
793    type_aware_require: Option<TypeAwareRequireArg>,
794}
795
796impl Cli {
797    /// Tri-state CLI override for type-aware analysis: `Some(true)` for
798    /// `--type-aware`, `Some(false)` for `--no-type-aware`, `None` when
799    /// neither flag was passed (environment and config decide).
800    const fn type_aware_override(&self) -> Option<bool> {
801        if self.no_type_aware {
802            Some(false)
803        } else if self.type_aware {
804            Some(true)
805        } else {
806            None
807        }
808    }
809
810    /// The global `--dupes-*` overrides of the `duplicates` config.
811    const fn dupes_overrides(&self) -> DupesOverrides {
812        DupesOverrides {
813            mode: self.dupes_mode,
814            near: self.dupes_near,
815            threshold: self.dupes_threshold,
816            min_tokens: self.dupes_min_tokens,
817            min_lines: self.dupes_min_lines,
818            min_occurrences: self.dupes_min_occurrences,
819            skip_local: self.dupes_skip_local,
820            ignore_symlinks: resolve_negatable_flag(
821                self.dupes_ignore_symlinks,
822                self.dupes_no_ignore_symlinks,
823            ),
824            cross_language: self.dupes_cross_language,
825            ignore_imports: resolve_negatable_flag(
826                self.dupes_ignore_imports,
827                self.dupes_no_ignore_imports,
828            ),
829        }
830    }
831}
832
833#[derive(Clone, Copy, Subcommand)]
834enum TypeAwareCli {
835    /// Report companion availability and version compatibility without analysis.
836    Status,
837}
838
839#[derive(Subcommand)]
840enum Command {
841    /// Analyze project for unused code and dependency hygiene
842    #[command(name = "dead-code", alias = "check")]
843    Check {
844        /// Only report unused files
845        #[arg(long)]
846        unused_files: bool,
847
848        /// Only report unused exports
849        #[arg(long)]
850        unused_exports: bool,
851
852        /// Only report unused dependencies
853        #[arg(long)]
854        unused_deps: bool,
855
856        /// Only report unused type exports
857        #[arg(long)]
858        unused_types: bool,
859
860        /// Opt in to private type leak API hygiene findings and only report that issue type
861        #[arg(long)]
862        private_type_leaks: bool,
863
864        /// Opt in to `@deprecated` exports that are still in use and only report that issue type
865        #[arg(long)]
866        deprecated_exports_in_use: bool,
867
868        /// Only report unused enum members
869        #[arg(long)]
870        unused_enum_members: bool,
871
872        /// Only report unused class members
873        #[arg(long)]
874        unused_class_members: bool,
875
876        /// Only report unused store members
877        #[arg(long)]
878        unused_store_members: bool,
879
880        /// Only report unprovided injects
881        #[arg(long)]
882        unprovided_injects: bool,
883
884        /// Only report unrendered components
885        #[arg(long)]
886        unrendered_components: bool,
887
888        /// Only report unused component props
889        #[arg(long)]
890        unused_component_props: bool,
891
892        /// Review optional props omitted by known reachable callers (enables this rule)
893        #[arg(long)]
894        absent_component_props: bool,
895
896        /// Only report unused component emits
897        #[arg(long)]
898        unused_component_emits: bool,
899
900        /// Only report unused component inputs
901        #[arg(long)]
902        unused_component_inputs: bool,
903
904        /// Only report unused component outputs
905        #[arg(long)]
906        unused_component_outputs: bool,
907
908        /// Only report unused Svelte dispatched events
909        #[arg(long)]
910        unused_svelte_events: bool,
911
912        /// Only report unused server actions
913        #[arg(long)]
914        unused_server_actions: bool,
915
916        /// Only report unused SvelteKit load() data keys
917        #[arg(long)]
918        unused_load_data_keys: bool,
919
920        /// Only report unresolved imports
921        #[arg(long)]
922        unresolved_imports: bool,
923
924        /// Only report unlisted dependencies
925        #[arg(long)]
926        unlisted_deps: bool,
927
928        /// Only report duplicate exports
929        #[arg(long)]
930        duplicate_exports: bool,
931
932        /// Only report circular dependencies.
933        /// Deprecated alias: use `fallow architecture --cycles`.
934        #[arg(long)]
935        circular_deps: bool,
936
937        /// Only report re-export cycles.
938        /// Deprecated alias: use `fallow architecture --cycles`.
939        #[arg(long)]
940        re_export_cycles: bool,
941
942        /// Only report dependency cycles between workspace packages.
943        /// Deprecated alias: use `fallow architecture --cycles`.
944        #[arg(long)]
945        package_cycles: bool,
946
947        /// Only report boundary violations.
948        /// Deprecated alias: use `fallow architecture --boundaries`.
949        #[arg(long)]
950        boundary_violations: bool,
951
952        /// Only report rule-pack policy violations.
953        /// Deprecated alias: use `fallow architecture --policy`.
954        #[arg(long)]
955        policy_violations: bool,
956
957        /// Only report stale suppressions
958        #[arg(long)]
959        stale_suppressions: bool,
960
961        /// Only report unused pnpm catalog entries
962        #[arg(long)]
963        unused_catalog_entries: bool,
964
965        /// Only report empty pnpm catalog groups
966        #[arg(long)]
967        empty_catalog_groups: bool,
968
969        /// Only report unresolved pnpm catalog references
970        #[arg(long)]
971        unresolved_catalog_references: bool,
972
973        /// Only report unused package-manager dependency overrides
974        #[arg(long)]
975        unused_dependency_overrides: bool,
976
977        /// Only report misconfigured package-manager dependency overrides
978        #[arg(long)]
979        misconfigured_dependency_overrides: bool,
980
981        /// Also run duplication analysis and cross-reference with dead code
982        #[arg(long)]
983        include_dupes: bool,
984
985        /// Trace why an export is used/unused (format: `FILE:EXPORT_NAME`)
986        #[arg(long, value_name = "FILE:EXPORT")]
987        trace: Option<String>,
988
989        /// Trace all edges for a file (imports, exports, importers)
990        #[arg(long, value_name = "PATH")]
991        trace_file: Option<String>,
992
993        /// Trace where a dependency is used
994        #[arg(long, value_name = "PACKAGE")]
995        trace_dependency: Option<String>,
996
997        /// Compute the impact closure for a file (the transitive
998        /// affected-but-not-in-diff set + coordination gap). Walks reverse-deps
999        /// and re-export chains; powers the `inspect_target` MCP tool.
1000        #[arg(long, value_name = "PATH")]
1001        impact_closure: Option<String>,
1002
1003        /// Compute exact-symbol consumers, affected files, and targeted tests.
1004        #[arg(long, value_name = "FILE:EXPORT")]
1005        symbol_impact: Option<String>,
1006
1007        /// Show only the top N items per category. Human output only: the JSON,
1008        /// SARIF, and CodeClimate envelopes drive exit codes and CI baselines,
1009        /// so they always carry every finding and a consumer slices the arrays
1010        /// itself.
1011        #[arg(long)]
1012        top: Option<usize>,
1013
1014        /// Only report issues in the specified file(s). Accepts multiple values.
1015        /// The full project graph is still built, but only issues in matching files
1016        /// are reported. Useful for lint-staged pre-commit hooks.
1017        #[arg(long, value_name = "PATH")]
1018        file: Vec<std::path::PathBuf>,
1019
1020        /// Only report the findings with these `finding_id` values. Repeat the flag
1021        /// or pass a comma-separated list. Ids stay the same under every filter.
1022        /// The JSON output adds `finding_id_query`: a missing id means "resolved"
1023        /// only when `conclusive` is true.
1024        #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1025        finding_id: Vec<String>,
1026
1027        /// Scope reported findings to this file or directory (default: whole project).
1028        /// The full project graph is still built; only reported items are narrowed.
1029        #[arg(value_name = "PATH")]
1030        path: Option<std::path::PathBuf>,
1031    },
1032
1033    /// Check import cycles, boundaries and policy rules after editing
1034    ///
1035    /// Run `fallow guard <files>` before an edit to see the rules that apply to
1036    /// those files.
1037    Architecture {
1038        /// Only report import cycles: circular dependencies, re-export cycles
1039        /// and package cycles
1040        #[arg(long)]
1041        cycles: bool,
1042
1043        /// Only report boundary violations, boundary coverage and forbidden calls
1044        #[arg(long)]
1045        boundaries: bool,
1046
1047        /// Only report rule-pack policy violations
1048        #[arg(long)]
1049        policy: bool,
1050
1051        /// Only report issues in the specified file(s). Accepts multiple values.
1052        /// The full project graph is still built, but only issues in matching files
1053        /// are reported.
1054        #[arg(long, value_name = "PATH")]
1055        file: Vec<std::path::PathBuf>,
1056
1057        /// Only report the findings with these `finding_id` values. Repeat the flag
1058        /// or pass a comma-separated list.
1059        #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1060        finding_id: Vec<String>,
1061
1062        /// Scope reported findings to this file or directory (default: whole project).
1063        /// The full project graph is still built; only reported items are narrowed.
1064        #[arg(value_name = "PATH")]
1065        path: Option<std::path::PathBuf>,
1066    },
1067
1068    /// Watch for changes and re-run analysis
1069    Watch {
1070        /// Don't clear the screen between re-analyses
1071        #[arg(long)]
1072        no_clear: bool,
1073    },
1074
1075    /// Inspect the optional TypeScript semantic companion.
1076    TypeAware {
1077        #[command(subcommand)]
1078        subcommand: TypeAwareCli,
1079    },
1080
1081    /// Diagnose project readiness without analysis or mutation.
1082    ///
1083    /// Checks the root, config resolution, workspace discovery, external
1084    /// plugins, and the optional type-aware companion. Uses only local reads;
1085    /// it performs no cache writes, telemetry, network requests, or third-party
1086    /// execution. Supports human and JSON output.
1087    #[command(override_help = doctor::HELP)]
1088    Doctor,
1089
1090    /// Find semantically similar functions with a pinned local model (opt-in).
1091    ///
1092    /// Results are unverified candidates. The model score is not a probability,
1093    /// gate, vulnerability verdict, or safe-refactor decision. Model setup is
1094    /// explicit, and project source remains local and offline during analysis.
1095    SimilarCode {
1096        #[command(subcommand)]
1097        subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
1098        /// Minimum cosine similarity retained as an unverified candidate.
1099        #[arg(long, value_name = "0..1")]
1100        threshold: Option<f64>,
1101        /// Minimum source lines per extracted function.
1102        #[arg(long, value_name = "N")]
1103        min_lines: Option<usize>,
1104        /// Cap displayed candidates after bounded full-corpus comparison.
1105        #[arg(long, value_name = "N")]
1106        top: Option<usize>,
1107        /// Report pairs touching one of these project-relative files.
1108        #[arg(long, value_name = "PATH")]
1109        file: Vec<PathBuf>,
1110
1111        /// Scope reported findings to this file or directory (default: whole project).
1112        /// The full project graph is still built; only reported items are narrowed.
1113        #[arg(value_name = "PATH")]
1114        path: Option<PathBuf>,
1115    },
1116
1117    /// Inspect one file or exported symbol as a bundled evidence query
1118    Inspect {
1119        /// File to inspect.
1120        #[arg(
1121            long,
1122            value_name = "PATH",
1123            conflicts_with = "symbol",
1124            required_unless_present = "symbol"
1125        )]
1126        file: Option<String>,
1127
1128        /// Exported symbol to inspect, formatted as FILE:EXPORT.
1129        #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
1130        symbol: Option<String>,
1131
1132        /// OPT-IN: also attach the best-effort symbol-level call chain
1133        /// (`fallow trace`) as the `symbol_chain` evidence section. Only
1134        /// meaningful for a `--symbol` target. Default off (best-effort,
1135        /// syntactic, OFF the ranked path).
1136        #[arg(long)]
1137        symbol_chain: bool,
1138
1139        /// OPT-IN: attach target-level git churn evidence from the health
1140        /// hotspot subsystem. Default off to avoid git-history latency.
1141        #[arg(long)]
1142        churn: bool,
1143    },
1144
1145    /// Trace a symbol's call chain, or the shortest import path between two
1146    /// modules (best-effort, syntactic; OFF the ranked path)
1147    ///
1148    /// Walks callers UP (modules that import the symbol) and callees DOWN
1149    /// (import-symbol edges + intra-module call sites) via the module graph,
1150    /// bounded by `--depth`. Symbol-level chains are labeled best-effort per
1151    /// ADR-001: resolved-vs-unresolved callees are reported honestly, never
1152    /// silently dropped. The result is its OWN surface, NOT folded into the
1153    /// ranked brief and NEVER an input to the focus map / ranking.
1154    ///
1155    /// `--path <FROM> <TO>` answers a different question: the shortest chain of
1156    /// imports by which one module reaches another. Type-only hops are reported
1157    /// rather than skipped, and an unreachable pair reports `reachable: false`
1158    /// instead of an error.
1159    Trace {
1160        /// Target symbol, formatted as FILE:SYMBOL (e.g. src/utils.ts:formatDate).
1161        /// Omitted when `--path` or `--dependency` is used.
1162        #[arg(
1163            value_name = "FILE:SYMBOL",
1164            required_unless_present_any = ["path", "dependency"]
1165        )]
1166        symbol: Option<String>,
1167
1168        /// How the code uses each imported name of a package: file and call
1169        /// counts, one hop through project wrappers, and a count of each use
1170        /// that the trace cannot resolve
1171        ///
1172        /// `--sites`, `--specifier`, `--limit` and `--cursor` page the usage
1173        /// sites. `--callers --depth N` adds the files that import the users.
1174        #[arg(
1175            long,
1176            value_name = "PACKAGE",
1177            conflicts_with_all = ["symbol", "path", "callees"]
1178        )]
1179        dependency: Option<String>,
1180
1181        /// With `--dependency`, report only these imported names
1182        /// (repeatable or comma-separated). Turns on the site page.
1183        #[arg(
1184            long,
1185            value_name = "NAME",
1186            value_delimiter = ',',
1187            requires = "dependency"
1188        )]
1189        specifier: Vec<String>,
1190
1191        /// With `--dependency`, list the usage sites with file, line and column
1192        #[arg(long, requires = "dependency")]
1193        sites: bool,
1194
1195        /// With `--dependency`, the largest number of sites on a page
1196        /// (1 to 500, default 50). Turns on the site page.
1197        #[arg(
1198            long,
1199            value_name = "N",
1200            requires = "dependency",
1201            value_parser = clap::value_parser!(u16).range(1..=500)
1202        )]
1203        limit: Option<u16>,
1204
1205        /// With `--dependency`, the `next_cursor` of the previous page. Turns
1206        /// on the site page.
1207        #[arg(long, value_name = "TOKEN", requires = "dependency")]
1208        cursor: Option<String>,
1209
1210        /// Shortest import path between two modules, as two file paths
1211        /// (e.g. `--path src/app.ts src/db.ts`). Mutually exclusive with the
1212        /// symbol target and the call-chain flags.
1213        #[arg(
1214            long,
1215            num_args = 2,
1216            value_names = ["FROM", "TO"],
1217            conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1218        )]
1219        path: Vec<String>,
1220
1221        /// With `--path`, follow only static value imports, so the route
1222        /// explains why TO loads before FROM runs
1223        ///
1224        /// `import()`, lazy globs, worker loads and `import type` do not
1225        /// qualify.
1226        #[arg(long, requires = "path")]
1227        eager_only: bool,
1228
1229        /// Walk UP to callers (modules that import the symbol). When neither
1230        /// `--callers` nor `--callees` is set, both directions are walked.
1231        #[arg(long)]
1232        callers: bool,
1233
1234        /// Walk DOWN to callees (the symbol's module's import-symbol edges plus
1235        /// unresolved call sites). When neither flag is set, both are walked.
1236        #[arg(long)]
1237        callees: bool,
1238
1239        /// Chain depth bound for both directions (default 2). Symbol-level is
1240        /// best-effort, so a shallow bound keeps the trace legible.
1241        #[arg(long, value_name = "N")]
1242        depth: Option<u32>,
1243    },
1244
1245    /// Resolve a runtime stack trace's frames to the definitions they name
1246    /// (best-effort, syntactic; OFF the ranked path)
1247    ///
1248    /// Reads a V8 / Node or SpiderMonkey / JavaScriptCore stack trace from a
1249    /// file or from stdin, classifies every frame as in-project,
1250    /// node-modules, or out-of-corpus, and asks the module graph which
1251    /// definitions the in-project frames name.
1252    ///
1253    /// The result refuses to overclaim. A frame matching several definitions
1254    /// reports `ambiguous` and lists all of them rather than picking one; a
1255    /// frame matching nothing reports `not_found` rather than disappearing; a
1256    /// frame the graph was never asked about reports `not_attempted`. Every
1257    /// frame read stays in the array in input order and `counts` publishes the
1258    /// per-outcome totals, so a caller can see what went unanswered.
1259    ///
1260    /// No source maps are read. A frame inside generated build output is
1261    /// reported as such, because a stale map rebinds silently to the wrong
1262    /// line.
1263    #[command(name = "trace-error")]
1264    TraceError {
1265        /// Stack trace file, or `-` to read stdin. Defaults to stdin. A
1266        /// relative path is resolved against the project root, matching
1267        /// `--diff-file`.
1268        #[arg(value_name = "FILE")]
1269        trace_file: Option<String>,
1270    },
1271
1272    /// Auto-fix issues: remove unused exports, dependencies, and enum
1273    /// members; add duplicate-export rules to a fallow config file.
1274    ///
1275    /// When no fallow config exists outside a monorepo subpackage, a
1276    /// fresh `.fallowrc.json` is created from the same scaffolding
1277    /// `fallow init` would emit (framework detection, `$schema`,
1278    /// `entry`, etc.) and the duplicate-export rules are layered on
1279    /// top. Inside a monorepo subpackage the create-fallback refuses
1280    /// and points at the workspace root. Pass `--no-create-config` to
1281    /// opt out of the create-fallback (recommended for pre-commit
1282    /// hooks, CI bots, and `fallow watch`).
1283    ///
1284    /// Use `--dry-run` to preview source-file edits and config-file
1285    /// diffs without writing.
1286    Fix {
1287        /// Dry run, show what would be changed without modifying files
1288        #[arg(long)]
1289        dry_run: bool,
1290
1291        /// Skip confirmation prompt (required in non-TTY environments like CI or AI agents)
1292        #[arg(long, alias = "force")]
1293        yes: bool,
1294
1295        /// Refuse to create a new fallow config file when none exists.
1296        /// Use this from pre-commit hooks, CI bots, and `fallow watch`
1297        /// where silently materialising a new top-level config file would
1298        /// surprise the user. The duplicate-export config-add path is
1299        /// skipped with an explanatory message; source-file edits proceed
1300        /// normally.
1301        #[arg(long)]
1302        no_create_config: bool,
1303
1304        /// Scope reported findings to this file or directory (default: whole project).
1305        /// The full project graph is still built; only reported items are narrowed.
1306        /// Only fixes touching scoped files are planned and applied.
1307        #[arg(value_name = "PATH")]
1308        path: Option<PathBuf>,
1309    },
1310
1311    /// Initialize a .fallowrc.json configuration file, AGENTS.md guide, or git
1312    /// pre-commit hook. Use `.fallowrc.jsonc` for editor-native JSON-with-comments
1313    /// support; both extensions are auto-discovered.
1314    ///
1315    /// `--hooks` scaffolds a shell-level Git pre-commit hook under
1316    /// `.git/hooks/` that runs fallow on changed files. The clearer hook
1317    /// namespace is `fallow hooks install --target git`; `init --hooks`
1318    /// remains as a convenience during project initialization.
1319    Init {
1320        /// Generate TOML instead of JSONC
1321        #[arg(long)]
1322        toml: bool,
1323
1324        /// Scaffold a starter AGENTS.md guidance file for coding agents
1325        #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1326        agents: bool,
1327
1328        /// Scaffold a shell-level pre-commit git hook in `.git/hooks/` that
1329        /// runs fallow on changed files. Alias for
1330        /// `fallow hooks install --target git`.
1331        #[arg(long)]
1332        hooks: bool,
1333
1334        /// Fallback base branch/ref for the pre-commit hook when no upstream is set
1335        #[arg(long, requires = "hooks")]
1336        branch: Option<String>,
1337
1338        /// Record that this project deliberately stays unconfigured: persists a
1339        /// decline so the first-contact setup hint and the `setup` next-step
1340        /// stop appearing here. Writes no config file; idempotent
1341        #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1342        decline: bool,
1343    },
1344
1345    /// Install or remove fallow-managed Git and agent hooks.
1346    ///
1347    /// Use `fallow hooks install --target git` for a shell-level Git
1348    /// pre-commit hook. Use `fallow hooks install --target agent` for a
1349    /// Claude Code / Codex gate that blocks agent `git commit` / `git push`
1350    /// commands until `fallow audit` passes.
1351    Hooks {
1352        #[command(subcommand)]
1353        subcommand: HooksCli,
1354    },
1355
1356    /// Wire fallow into the coding-agent harnesses used by this project in
1357    /// one pass (AGENTS.md task map, skill, MCP server, commit/push gate), or
1358    /// show and remove what was installed. `fallow init --agents` and
1359    /// `fallow hooks install --target agent` remain the single-piece
1360    /// commands underneath.
1361    Agent {
1362        #[command(subcommand)]
1363        subcommand: AgentCli,
1364    },
1365
1366    /// CI helpers for PR/MR feedback envelopes.
1367    Ci {
1368        #[command(subcommand)]
1369        subcommand: CiCli,
1370    },
1371
1372    /// Print the JSON Schema for fallow configuration files
1373    ConfigSchema,
1374
1375    /// Print the JSON Schema for external plugin files
1376    PluginSchema,
1377
1378    /// Dry-run external plugins: report what each activated and seeded
1379    PluginCheck,
1380
1381    /// Print the JSON Schema for rule pack files
1382    RulePackSchema,
1383
1384    /// Manage declarative rule packs (policy-as-code)
1385    RulePack {
1386        #[command(subcommand)]
1387        subcommand: RulePackCli,
1388    },
1389
1390    /// Show which architecture rules apply to files before changing them.
1391    Guard {
1392        /// Files to report on (root-relative or absolute; may not exist yet)
1393        #[arg(required = true, num_args = 1..)]
1394        files: Vec<String>,
1395    },
1396
1397    /// Show the resolved config and which config file was loaded
1398    ///
1399    /// Walks up from the project root looking for `.fallowrc.json`,
1400    /// `.fallowrc.jsonc`, `fallow.toml`, or `.fallow.toml`, resolves `extends`, and prints
1401    /// the final config as JSON. Use `--path` to print only the config
1402    /// file path (useful in shell scripts). The default view always exits 0:
1403    /// it prints the loaded config, or, on a zero-config project, the effective
1404    /// defaults (fully supported). `--path` exits 3 when no config file exists,
1405    /// since there is no path to report.
1406    ///
1407    /// Precedence is first-match-wins per directory, in the order
1408    /// `.fallowrc.json` > `.fallowrc.jsonc` > `fallow.toml` > `.fallow.toml`,
1409    /// walking up to the workspace root. `.fallowrc.json` accepts JSONC
1410    /// (comments and trailing commas); `.fallowrc.jsonc` is identical in
1411    /// behavior, the extension only signals to editors that comments are
1412    /// expected. If two config files coexist in one directory, fallow loads the
1413    /// higher-precedence one and warns on stderr naming the file it ignored.
1414    Config {
1415        /// Print only the config file path (one line, no JSON)
1416        #[arg(long)]
1417        path: bool,
1418    },
1419
1420    /// Recommend a project-tailored config for an agent to author.
1421    ///
1422    /// Read-only. Inspects the project (frameworks, workspace layout, tooling)
1423    /// and emits what fallow detected, a safe proposed config, and a list of
1424    /// decisions split into auto (decided from detection), default (a disclosed
1425    /// overridable default), and taste (a genuinely subjective choice surfaced
1426    /// to the user as an open question). Honors `--root` and `--format`.
1427    Recommend,
1428
1429    /// List discovered entry points, files, plugins, boundaries, workspaces, and the
1430    /// startup import weight.
1431    List {
1432        /// Show entry points
1433        #[arg(long)]
1434        entry_points: bool,
1435
1436        /// Show all discovered files
1437        #[arg(long)]
1438        files: bool,
1439
1440        /// Show active plugins
1441        #[arg(long)]
1442        plugins: bool,
1443
1444        /// Show architecture boundary zones, rules, and per-zone file counts
1445        #[arg(long)]
1446        boundaries: bool,
1447
1448        /// Show monorepo workspaces and any workspace-discovery diagnostics
1449        /// (malformed package.json, unreachable glob matches, missing
1450        /// tsconfig references).
1451        #[arg(long)]
1452        workspaces: bool,
1453
1454        /// Show the startup import weight of each runtime entry point, in
1455        /// source bytes (not bundle size)
1456        ///
1457        /// For each runtime entry: the modules and source bytes that load
1458        /// before the entry runs, the modules behind `import()` and behind
1459        /// workers or forks, the packages on the startup path, and the single
1460        /// imports that keep the most bytes eager. Source bytes include types
1461        /// and comments. An import without the `type` keyword counts as eager,
1462        /// even when it brings in only types that TypeScript removes.
1463        #[arg(long)]
1464        entry_weight: bool,
1465
1466        /// Scope reported findings to this file or directory (default: whole project).
1467        /// The full project graph is still built; only reported items are narrowed.
1468        #[arg(value_name = "PATH")]
1469        path: Option<PathBuf>,
1470    },
1471
1472    /// Show monorepo workspaces and any workspace-discovery diagnostics.
1473    ///
1474    /// Equivalent to `fallow list --workspaces`. Use this dedicated form
1475    /// when introspecting only the workspace topology (other `list`
1476    /// sections stay hidden).
1477    Workspaces,
1478
1479    /// Find code duplication / clones across the project
1480    Dupes {
1481        /// Detection mode: strict, mild, weak, or semantic
1482        /// (defaults to the value in `.fallowrc.jsonc`, or `mild` if unset).
1483        #[arg(long)]
1484        mode: Option<DupesMode>,
1485
1486        /// Enable function-scoped near-miss clone detection.
1487        #[arg(long)]
1488        near: bool,
1489
1490        /// Minimum token count for a clone
1491        /// (defaults to the value in `.fallowrc.jsonc`, or `50` if unset).
1492        #[arg(long)]
1493        min_tokens: Option<usize>,
1494
1495        /// Minimum line count for a clone
1496        /// (defaults to the value in `.fallowrc.jsonc`, or `5` if unset).
1497        #[arg(long)]
1498        min_lines: Option<usize>,
1499
1500        /// Minimum number of occurrences before a clone group is reported.
1501        /// Raise to focus on widespread copy-paste worth refactoring and skip
1502        /// pair-only clones.
1503        /// (defaults to the value in `.fallowrc.jsonc`, or `2` if unset).
1504        #[arg(long, value_parser = parse_min_occurrences)]
1505        min_occurrences: Option<usize>,
1506
1507        /// Fail if duplication exceeds this percentage (0 = no limit)
1508        /// (defaults to the value in `.fallowrc.jsonc`, or `0` if unset).
1509        #[arg(long)]
1510        threshold: Option<f64>,
1511
1512        /// Only report cross-directory duplicates
1513        #[arg(long)]
1514        skip_local: bool,
1515
1516        /// Enable cross-language detection (strip TS type annotations for TS↔JS matching)
1517        #[arg(long)]
1518        cross_language: bool,
1519
1520        /// Exclude module wiring from clone detection (default; covers imports,
1521        /// re-exports, and top-level static require bindings). Pass
1522        /// `--no-ignore-imports` to count it again.
1523        #[arg(long)]
1524        ignore_imports: bool,
1525
1526        /// Count module wiring as clone candidates (opt out of the default
1527        /// exclusion).
1528        #[arg(long, conflicts_with = "ignore_imports")]
1529        no_ignore_imports: bool,
1530
1531        /// Omit clone instances whose path is a symlink, or lies under a
1532        /// symlinked directory. A clone group with fewer than two remaining
1533        /// instances is not reported. Without this flag, JSON output marks
1534        /// these instances with `is_symlink: true`.
1535        #[arg(long)]
1536        ignore_symlinks: bool,
1537
1538        /// Report symlinked clone instances (opt out of a config
1539        /// `duplicates.ignoreSymlinks: true`).
1540        #[arg(long, conflicts_with = "ignore_symlinks")]
1541        no_ignore_symlinks: bool,
1542
1543        /// Show only the N highest-ranked clone groups. Ranking combines clone
1544        /// size, occurrence count, and capped directory or line spread.
1545        /// `clone_families[]` is rebuilt from the groups that survive, so it
1546        /// narrows too. `stats` keeps describing the whole corpus;
1547        /// `clone_groups_shown` / `clone_groups_omitted` and
1548        /// `clone_families_shown` / `clone_families_omitted` report the split.
1549        /// Refused with exit code 2 alongside `--group-by`: grouped output
1550        /// reports per-bucket stats over every clone group in the bucket, which
1551        /// a global top-N truncation would silently contradict.
1552        #[arg(long)]
1553        top: Option<usize>,
1554
1555        /// Omit the verbatim source text from each clone instance in
1556        /// `--format json`. The file and line/column range still address the
1557        /// same code, and this is most of the payload on a duplicated codebase.
1558        #[arg(long)]
1559        no_fragments: bool,
1560
1561        /// Trace all clones at a specific location (format: `FILE:LINE`)
1562        #[arg(long, value_name = "FILE:LINE")]
1563        trace: Option<String>,
1564
1565        /// Scope reported findings to this file or directory (default: whole project).
1566        /// The full project graph is still built; only reported items are narrowed.
1567        #[arg(value_name = "PATH")]
1568        path: Option<PathBuf>,
1569    },
1570
1571    /// Analyze function complexity (cyclomatic + cognitive)
1572    ///
1573    /// By default, shows all existing sections: health score, complexity findings,
1574    /// file scores, hotspots, and refactoring targets. When any section flag is
1575    /// specified, only those sections are shown.
1576    Health {
1577        /// Maximum cyclomatic complexity threshold (overrides config)
1578        #[arg(long)]
1579        max_cyclomatic: Option<u16>,
1580
1581        /// Maximum cognitive complexity threshold (overrides config)
1582        #[arg(long)]
1583        max_cognitive: Option<u16>,
1584
1585        /// Maximum CRAP score threshold (overrides config, default 30.0).
1586        /// Functions meeting or exceeding this score are reported alongside
1587        /// complexity findings. Pair with `--coverage` for accurate scoring.
1588        #[arg(long)]
1589        max_crap: Option<f64>,
1590
1591        /// Show only the N most complex functions
1592        #[arg(long)]
1593        top: Option<usize>,
1594
1595        /// Sort by: cyclomatic (default), cognitive, lines, or severity
1596        #[arg(long, default_value = "cyclomatic")]
1597        sort: SortBy,
1598
1599        /// Show only complexity findings (functions exceeding thresholds).
1600        /// By default all sections are shown; use this to select only complexity.
1601        #[arg(long)]
1602        complexity: bool,
1603
1604        /// Include the per-decision-point complexity breakdown (`contributions[]`)
1605        /// on each complexity finding in `--format json` output. Each entry names
1606        /// the construct (if, else-if, loop, boolean operator, ...) and its
1607        /// cyclomatic/cognitive weight, so a consumer can explain WHY a function
1608        /// scored high. Used by the VS Code inline editor breakdown. Off by
1609        /// default to keep CI/default output lean.
1610        #[arg(long)]
1611        complexity_breakdown: bool,
1612
1613        /// Show only per-file health scores (fan-in, fan-out, dead code ratio, maintainability index).
1614        /// Requires full analysis pipeline (graph + dead code detection).
1615        /// Sorted by risk-aware triage concern: lower MI and higher CRAP risk first.
1616        /// --sort and --baseline apply to complexity findings only, not file scores.
1617        #[arg(long)]
1618        file_scores: bool,
1619
1620        /// Show only static test coverage gaps: runtime files and exports with no
1621        /// dependency path from any discovered test root. Requires full analysis pipeline.
1622        #[arg(long)]
1623        coverage_gaps: bool,
1624
1625        /// Show only hotspots: files that are both complex and frequently changing.
1626        /// Combines git churn history with complexity data. Requires a git repository.
1627        #[arg(long)]
1628        hotspots: bool,
1629
1630        /// Attach ownership signals to hotspot entries: bus factor, contributor
1631        /// count, declared CODEOWNERS owner, and ownership drift. Implies
1632        /// `--hotspots`. Requires a git repository.
1633        #[arg(long)]
1634        ownership: bool,
1635
1636        /// Privacy mode for author emails emitted with `--ownership`.
1637        /// Defaults to `handle` (local-part only). Use `raw` for OSS repos
1638        /// where authors are public, or `anonymized` to emit non-reversible
1639        /// pseudonyms in regulated environments. Implies `--ownership`.
1640        #[arg(long, value_name = "MODE", value_enum)]
1641        ownership_emails: Option<EmailModeArg>,
1642
1643        /// Show only refactoring targets: ranked recommendations based on complexity,
1644        /// coupling, churn, and dead code signals. Requires full analysis pipeline.
1645        #[arg(long)]
1646        targets: bool,
1647
1648        /// Show advisory project-local public-signature type coupling. Requires
1649        /// type-aware analysis and does not change the health score.
1650        #[arg(long)]
1651        type_coupling: bool,
1652
1653        /// Add structural CSS analytics: specificity hotspots, !important density,
1654        /// over-complex selectors, deep nesting, and conservative cleanup
1655        /// candidates. Standard CSS is parsed structurally; preprocessor sources
1656        /// are scanned only where fallow can avoid expanding Sass/Less semantics.
1657        #[arg(long)]
1658        css: bool,
1659
1660        /// Filter refactoring targets by effort level (low, medium, high).
1661        /// Implies --targets.
1662        #[arg(long, value_enum)]
1663        effort: Option<EffortFilter>,
1664
1665        /// Show only the project health score (0–100) with letter grade (A/B/C/D/F).
1666        /// The score is included by default when no section flags are set.
1667        #[arg(long)]
1668        score: bool,
1669
1670        /// Fail if the health score is below this threshold (0-100).
1671        /// Implies --score. The authoritative CI quality gate: when set,
1672        /// complexity findings become informational and the exit code is
1673        /// driven solely by the score (so --min-score 0 always exits 0).
1674        /// Composes with --min-severity (fails if either gate trips). Plain
1675        /// `fallow health` (no gate flag) stays advisory and exits 1 on any
1676        /// finding; for a gate on newly-introduced complexity use
1677        /// `fallow audit --gate new-only`.
1678        #[arg(long, value_name = "N")]
1679        min_score: Option<f64>,
1680
1681        /// Only exit with error for findings at or above this severity.
1682        /// Use --min-severity critical to ignore moderate/high findings in CI.
1683        /// Composes with --min-score (the run fails if either gate trips).
1684        /// The complexity-* rules apply first: a finding whose rule is warn
1685        /// never fails the run, whatever its severity.
1686        #[arg(long, value_name = "LEVEL", value_enum)]
1687        min_severity: Option<HealthSeverityCli>,
1688
1689        /// Print the score and findings but never fail CI (always exit 0).
1690        /// Advisory mode for surfacing health in logs without blocking.
1691        /// Mutually exclusive with --min-score and --min-severity.
1692        #[arg(long)]
1693        report_only: bool,
1694
1695        /// Git history window for hotspot analysis (default: 6m).
1696        /// Accepts durations (6m, 90d, 1y, 2w) or ISO dates (2025-06-01).
1697        #[arg(long, value_name = "DURATION")]
1698        since: Option<String>,
1699
1700        /// Minimum number of commits for a file to be included in hotspot ranking (default: 3)
1701        #[arg(long, value_name = "N")]
1702        min_commits: Option<u32>,
1703
1704        /// Save a vital signs snapshot for trend tracking.
1705        /// Defaults to `.fallow/snapshots/{timestamp}.json` if no path is given.
1706        /// A given path must resolve inside the project root, its Git work tree, the CI workspace or a temp directory.
1707        /// Forces file-scores, hotspot, and score computation for complete metrics.
1708        #[expect(
1709            clippy::option_option,
1710            reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1711        )]
1712        #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1713        save_snapshot: Option<Option<String>>,
1714
1715        /// Compare current metrics against the most recent saved snapshot.
1716        /// Reads from `.fallow/snapshots/` and shows per-metric deltas with
1717        /// directional indicators. Implies --score.
1718        #[arg(long)]
1719        trend: bool,
1720
1721        /// Compare current metrics against this snapshot file instead of the
1722        /// newest file in `.fallow/snapshots/`. Use it to restore a baseline
1723        /// from external storage in CI. With --group-by, groups are compared
1724        /// by key when the snapshot holds the same grouping. Implies --trend.
1725        #[arg(long, value_name = "PATH")]
1726        trend_from: Option<PathBuf>,
1727
1728        /// Path to coverage data for exact per-function CRAP scores. Accepts an
1729        /// Istanbul coverage map JSON file (coverage-final.json, from
1730        /// `jest --coverage`, `vitest run --coverage`, c8 or nyc), a directory
1731        /// containing coverage-final.json, a raw V8 coverage directory
1732        /// (`NODE_V8_COVERAGE=<dir> node --test`), or a single V8 coverage JSON
1733        /// file. Transpiled V8 scripts (tsx, bundles) map back to their source
1734        /// files through the source map that Node records in the dump. A
1735        /// script that differs from the file on disk and has no source map
1736        /// keeps the estimate. Use --coverage-root when
1737        /// the data was generated in a different environment (CI runner,
1738        /// Docker). Affects CRAP scores only, not --coverage-gaps. Also
1739        /// configurable via FALLOW_COVERAGE env var.
1740        #[arg(long, value_name = "PATH")]
1741        coverage: Option<PathBuf>,
1742
1743        /// Absolute prefix to strip from file paths in coverage data before
1744        /// prepending the project root. Use when coverage was generated in a
1745        /// different environment (CI runner, Docker). Example: if coverage paths
1746        /// start with /home/runner/work/myapp and the project root is ./,
1747        /// pass --coverage-root /home/runner/work/myapp.
1748        #[arg(long, value_name = "PATH")]
1749        coverage_root: Option<PathBuf>,
1750
1751        /// File or directory containing runtime coverage input. Accepts a
1752        /// V8 coverage directory, a single V8 JSON file, or a single
1753        /// Istanbul coverage map JSON file (commonly coverage-final.json).
1754        /// A single local capture is free. Continuous or multi-capture
1755        /// monitoring needs a license (see `fallow license`).
1756        #[arg(long, value_name = "PATH")]
1757        runtime_coverage: Option<PathBuf>,
1758
1759        /// Threshold for hot-path classification
1760        #[arg(long, default_value_t = 100)]
1761        min_invocations_hot: u64,
1762
1763        /// Minimum total trace volume before the sidecar allows high-confidence
1764        /// `safe_to_delete` / `review_required` verdicts. Below this the
1765        /// sidecar caps confidence at `medium` to protect against overconfident
1766        /// verdicts on new or low-traffic services. Omit to use the sidecar's
1767        /// spec default (5000).
1768        #[arg(long, value_name = "N")]
1769        min_observation_volume: Option<u32>,
1770
1771        /// Fraction of total trace count below which an invoked function is
1772        /// classified as `low_traffic` rather than `active`. Expressed as a
1773        /// decimal (e.g. `0.001` for 0.1%). Omit to use the sidecar's spec
1774        /// default (0.001).
1775        #[arg(long, value_name = "RATIO")]
1776        low_traffic_threshold: Option<f64>,
1777
1778        /// Scope reported findings to this file or directory (default: whole project).
1779        /// The full project graph is still built; only reported items are narrowed.
1780        #[arg(value_name = "PATH")]
1781        path: Option<PathBuf>,
1782    },
1783
1784    /// Detect feature flag patterns in the codebase
1785    ///
1786    /// Identifies environment variable flags (process.env.FEATURE_*),
1787    /// SDK calls from common providers, and config object patterns (opt-in).
1788    /// Reports flag locations, detection confidence, and cross-reference
1789    /// with dead code findings.
1790    Flags {
1791        /// Show only the top N flags
1792        #[arg(long)]
1793        top: Option<usize>,
1794
1795        /// Add a retirement report: one row per flag, with the reasons the
1796        /// flag can be retired. Advisory only; nothing is removed.
1797        #[arg(long)]
1798        retirement: bool,
1799
1800        /// Keep only retirement rows with this reason (repeatable)
1801        #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1802        reasons: Vec<flags::RetirementReasonArg>,
1803
1804        /// Order of the retirement rows
1805        #[arg(
1806            long,
1807            value_name = "KEY",
1808            requires = "retirement",
1809            default_value = "age"
1810        )]
1811        sort: flags::RetirementSortArg,
1812
1813        /// How to measure flag age: blame (lower bound), pickaxe (first
1814        /// commit with the name, slower) or off
1815        #[arg(
1816            long,
1817            value_name = "MODE",
1818            requires = "retirement",
1819            default_value = "blame"
1820        )]
1821        flag_age: flags::FlagAgeArg,
1822
1823        /// Keep only retirement rows at least this many days old
1824        #[arg(long, value_name = "DAYS", requires = "retirement")]
1825        min_age: Option<u64>,
1826
1827        /// Vendor flag export (JSON, read offline) that adds the
1828        /// fully-rolled-out, archived-in-vendor, missing-in-vendor and
1829        /// vendor-only reasons
1830        #[arg(long, value_name = "FILE", requires = "retirement")]
1831        flag_state: Option<std::path::PathBuf>,
1832
1833        /// Exit with code 1 when a flag in scope is older than this many
1834        /// days. Opt-in; needs a flag age
1835        #[arg(long, value_name = "DAYS", requires = "retirement")]
1836        max_flag_age: Option<u64>,
1837    },
1838
1839    /// List active fallow-ignore suppression markers (read-only inventory)
1840    ///
1841    /// Shows every `fallow-ignore-next-line` and `fallow-ignore-file` marker
1842    /// present in analyzed files, grouped per file with line, kind, level,
1843    /// and reason, plus project totals and a stale cross-reference against
1844    /// this run's stale-suppression findings. A governance surface, not a
1845    /// detector: always exits 0. Honors `--root`, `--format {human,json}`,
1846    /// `--workspace`, `--changed-workspaces`, `--changed-since`, and
1847    /// `--quiet`.
1848    Suppressions {
1849        /// Only list suppressions in the specified files. Accepts multiple values.
1850        #[arg(long, value_name = "PATH")]
1851        file: Vec<std::path::PathBuf>,
1852    },
1853
1854    /// Explain one fallow issue type without running an analysis.
1855    ///
1856    /// Prints the rule rationale, a worked example, fix guidance, and the
1857    /// relevant docs URL. Accepts values like `unused-export`,
1858    /// `fallow/unused-export`, `unused exports`, and `code duplication`.
1859    Explain {
1860        /// Issue type, issue label, or rule id to explain
1861        #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1862        issue_type: Vec<String>,
1863    },
1864
1865    /// Audit changed files for dead code, complexity, duplication, and styling.
1866    ///
1867    /// Purpose-built for reviewing AI-generated code and PR quality gates.
1868    /// Combines dead-code + complexity + duplication + styling scoped to
1869    /// changed files and returns a verdict (pass/warn/fail).
1870    ///
1871    /// `fallow audit` answers "will CI block this?": it gates (exit 1 on a
1872    /// fail verdict). The `review` alias plus `--brief` answer "where do I
1873    /// look?": the same analysis rendered as a deterministic orientation brief
1874    /// that ALWAYS exits 0, so a reviewer or agent can read it regardless of
1875    /// the verdict. `--format` is orthogonal to `--brief`.
1876    /// When `--changed-since`/`--base` is unset, the base is the git merge-base
1877    /// against the branch's upstream or the remote default (`origin/HEAD`,
1878    /// `origin/main`, `origin/master`); set `FALLOW_AUDIT_BASE` to pin it.
1879    /// By default, only findings introduced by the changeset affect the verdict;
1880    /// inherited findings are reported with new-vs-inherited attribution and
1881    /// individual JSON findings include `introduced: true/false`. Use
1882    /// `--gate all` or `[audit] gate = "all"` to fail on every finding in
1883    /// changed files without running the extra base-snapshot attribution pass.
1884    ///
1885    /// The global --baseline / --save-baseline flags are rejected on audit.
1886    /// Use --dead-code-baseline, --health-baseline, and --dupes-baseline
1887    /// (or their config equivalents) because each sub-analysis uses a
1888    /// different baseline format.
1889    #[command(visible_alias = "review")]
1890    Audit {
1891        /// Run dead-code analysis in production mode for this audit.
1892        #[arg(long = "production-dead-code")]
1893        production_dead_code: bool,
1894
1895        /// Run health analysis in production mode for this audit.
1896        #[arg(long = "production-health")]
1897        production_health: bool,
1898
1899        /// Run duplication analysis in production mode for this audit.
1900        #[arg(long = "production-dupes")]
1901        production_dupes: bool,
1902
1903        /// Compare dead-code issues against a saved baseline
1904        /// (produced by `fallow dead-code --save-baseline`).
1905        #[arg(long)]
1906        dead_code_baseline: Option<PathBuf>,
1907
1908        /// Compare health findings against a saved baseline
1909        /// (produced by `fallow health --save-baseline`).
1910        #[arg(long)]
1911        health_baseline: Option<PathBuf>,
1912
1913        /// Compare duplication clone groups against a saved baseline
1914        /// (produced by `fallow dupes --save-baseline`).
1915        #[arg(long)]
1916        dupes_baseline: Option<PathBuf>,
1917
1918        /// Maximum CRAP score threshold (overrides config, default 30.0).
1919        /// Functions meeting or exceeding this score cause audit to fail.
1920        /// Pair with `--coverage` for accurate scoring.
1921        #[arg(long)]
1922        max_crap: Option<f64>,
1923
1924        /// Path to Istanbul coverage data (coverage-final.json) or raw V8
1925        /// coverage (a `NODE_V8_COVERAGE` directory or one V8 JSON file) for
1926        /// accurate per-function CRAP scores in the health sub-analysis. Also
1927        /// configurable via FALLOW_COVERAGE or health.coverage.
1928        #[arg(long, value_name = "PATH")]
1929        coverage: Option<PathBuf>,
1930
1931        /// Absolute prefix to strip from coverage data paths before CRAP matching.
1932        /// Use when coverage was generated under a different checkout root in CI or Docker.
1933        /// Also configurable via FALLOW_COVERAGE_ROOT or health.coverageRoot.
1934        #[arg(long, value_name = "PATH")]
1935        coverage_root: Option<PathBuf>,
1936
1937        /// Disable styling analytics in audit.
1938        #[arg(long = "no-css")]
1939        no_css: bool,
1940
1941        /// Enable deep CSS analysis for audit explicitly: project-wide styling
1942        /// reachability, narrowed back to changed anchors. Deep CSS is on by
1943        /// default; use this to override `audit.cssDeep = false`.
1944        #[arg(long)]
1945        css_deep: bool,
1946
1947        /// Disable deep CSS analysis while keeping local styling analytics on.
1948        #[arg(long = "no-css-deep")]
1949        no_css_deep: bool,
1950
1951        /// Which findings affect the audit verdict.
1952        ///
1953        /// new-only (default): fail only on findings introduced by the current
1954        /// changeset. all: fail on every finding in changed files and skip
1955        /// base-snapshot attribution.
1956        #[arg(long, value_enum)]
1957        gate: Option<AuditGateArg>,
1958
1959        /// Runtime coverage input. Accepts a V8 directory, a single V8 JSON
1960        /// file, or an Istanbul coverage map JSON. Runs the `fallow-cov`
1961        /// sidecar inside the audit, so the `hot-path-touched` verdict shows
1962        /// next to the dead-code and complexity findings without a second
1963        /// `fallow health` run in CI. The verdict is informational and does
1964        /// not change the exit code. A single local capture is free.
1965        /// Continuous or multi-capture monitoring needs a license (see
1966        /// `fallow license`).
1967        #[arg(long, value_name = "PATH")]
1968        runtime_coverage: Option<PathBuf>,
1969
1970        /// Threshold for hot-path classification, forwarded to the sidecar
1971        /// when `--runtime-coverage` is set.
1972        #[arg(long, default_value_t = 100)]
1973        min_invocations_hot: u64,
1974
1975        /// Internal marker identifying a gate run (e.g. `pre-commit`), set by
1976        /// the generated git hook so Fallow Impact can record a containment
1977        /// event when the gate blocks then clears. Hidden; never changes the
1978        /// verdict, exit code, or output.
1979        #[arg(long, value_name = "MARKER", hide = true)]
1980        gate_marker: Option<String>,
1981
1982        /// Render the deterministic review brief instead of the gating audit
1983        /// report. The brief answers "where do I look?" rather than "will CI
1984        /// block this?", runs the same analysis, and ALWAYS exits 0 (the
1985        /// verdict is carried informationally). Implied by `fallow review`.
1986        /// Orthogonal to `--format`.
1987        #[arg(long)]
1988        brief: bool,
1989
1990        /// Cap on the number of consequential structural decisions surfaced in
1991        /// the review brief's decision surface (the working-memory limit).
1992        /// Default 4; clamped to the 3-5 band (4 plus or minus 1). Only
1993        /// consulted on the brief path.
1994        #[arg(
1995            long,
1996            value_name = "N",
1997            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1998        )]
1999        max_decisions: usize,
2000
2001        /// Emit the agent-contract WALKTHROUGH GUIDE: the current digest
2002        /// (brief + decision surface), the review direction, the JSON schema the
2003        /// agent must return, and a deterministic graph-snapshot hash pinned into
2004        /// the digest. The digest is built from the graph only (PR prose is never
2005        /// folded in, so it is injection-resistant). Implies the brief; always
2006        /// exits 0. A thin agent skill calls this to fetch the current guide,
2007        /// produces judgment JSON, then reopens with `--walkthrough-file`.
2008        #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
2009        walkthrough_guide: bool,
2010
2011        /// Ingest an agent's judgment JSON and POST-VALIDATE it against the
2012        /// LIVE graph. Rejects any judgment whose `signal_id` fallow did not emit
2013        /// (anti-hallucination); refuses the whole payload as stale when the
2014        /// echoed graph-snapshot hash no longer matches (the tree moved); rejects
2015        /// an `action` outside `block`, `address`, `consider`, `fyi`
2016        /// (`invalid-action`). The verifier is the graph, not a second model.
2017        /// Implies the brief; always exits 0. The agent's free-text framing and
2018        /// action label are fenced as non-deterministic and never gate or
2019        /// auto-post.
2020        #[arg(long, value_name = "PATH")]
2021        walkthrough_file: Option<PathBuf>,
2022
2023        /// Render the existing walkthrough guide as a staged HUMAN terminal tour
2024        /// (Stage 1 load-bearing / Stage 2 mechanical), or markdown with
2025        /// `--format markdown`. Implies the brief; always exits 0.
2026        /// `--format json --walkthrough` emits the same agent-contract JSON as
2027        /// `--walkthrough-guide`.
2028        #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
2029        walkthrough: bool,
2030
2031        /// Record one or more changed files as VIEWED in the local walkthrough
2032        /// viewed-state ledger (`.fallow/walkthrough-state.json`), then render the
2033        /// tour. Files already viewed (and still current) collapse into the
2034        /// Cleared panel. Repeatable. Stale marks (the tree moved) are ignored on
2035        /// render but never deleted. Only consulted on the `--walkthrough` path.
2036        #[arg(long, value_name = "PATH")]
2037        mark_viewed: Vec<PathBuf>,
2038
2039        /// Expand the Cleared panel in the human/markdown walkthrough tour: list
2040        /// each de-prioritized and already-viewed file instead of the collapsed
2041        /// one-line summary. Only consulted on the `--walkthrough` path.
2042        #[arg(long)]
2043        show_cleared: bool,
2044
2045        /// Expand the de-prioritized units in the review brief's weighted
2046        /// focus map ("show me what you de-prioritized"). The `deprioritized`
2047        /// escape-hatch list is ALWAYS present in `--format json` regardless; this
2048        /// flag only re-expands the collapse-by-default human focus render. Only
2049        /// consulted on the `--walkthrough` path.
2050        #[arg(long)]
2051        show_deprioritized: bool,
2052
2053        /// Scope reported findings to this file or directory (default: whole project).
2054        /// The full project graph is still built; only reported items are narrowed.
2055        #[arg(value_name = "PATH")]
2056        path: Option<PathBuf>,
2057    },
2058
2059    /// Maintain reusable audit base-snapshot caches.
2060    AuditCache {
2061        #[command(subcommand)]
2062        subcommand: AuditCacheCli,
2063    },
2064
2065    /// Maintain the committed baseline files that the config points at.
2066    Baselines {
2067        #[command(subcommand)]
2068        subcommand: BaselinesCli,
2069    },
2070
2071    /// Surface the consequential structural DECISIONS a change embeds (the apex
2072    /// of the review brief), each framed as a judgment question with the routed
2073    /// expert to ask.
2074    ///
2075    /// The product's decision surface: a ranked, capped (4 plus or minus 1),
2076    /// signal_id-anchored set of the SOLID-3 decisions (coupling/boundary,
2077    /// exports-aware public-API/contract, dependency). Runs the same changed-code
2078    /// analysis as `fallow review` but emits ONLY the decisions, separable and
2079    /// cheap. Coupling and public-API decisions are suppressible with
2080    /// `// fallow-ignore`; a dependency decision anchors on `package.json` and
2081    /// has no suppress action. Never gates: exits 0 after a successful run, and
2082    /// exits 2 on invalid input (for example the global `--baseline` or
2083    /// `--save-baseline` flag) like every other command. Use `--base` /
2084    /// `--changed-since` to pick the comparison point, exactly like `fallow audit`.
2085    DecisionSurface {
2086        /// Cap on the number of surfaced decisions (the working-memory limit).
2087        /// Default 4; clamped to the 3-5 band (4 plus or minus 1).
2088        #[arg(
2089            long,
2090            value_name = "N",
2091            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
2092        )]
2093        max_decisions: usize,
2094    },
2095
2096    /// Show what fallow has done for you: how many issues it is surfacing, the
2097    /// trend since the last recorded run, and how many commits it contained at
2098    /// the pre-commit gate.
2099    ///
2100    /// Local-only and opt-in: enable per project with `fallow impact enable`, or
2101    /// turn it on everywhere with `fallow impact default on`, then let your
2102    /// `fallow audit` / pre-commit gate runs build history. History is stored in
2103    /// your user config dir (never written into the repo) and forced off in CI.
2104    /// Impact never uploads anything and never affects exit codes.
2105    Impact {
2106        #[command(subcommand)]
2107        subcommand: Option<ImpactCli>,
2108        /// Aggregate every tracked project into one cross-repo roll-up
2109        /// ("what has fallow done for me across all my repos"). Reads the
2110        /// user config dir; ignores `--root`. Cannot combine with a subcommand.
2111        #[arg(long)]
2112        all: bool,
2113        /// Row ordering for `--all` (default: most recently recorded first).
2114        #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
2115        sort: ImpactSortCli,
2116        /// Cap the number of `--all` rows printed (grand totals still reflect
2117        /// every tracked project).
2118        #[arg(long)]
2119        limit: Option<usize>,
2120    },
2121
2122    /// Surface local security candidates for downstream agent verification (opt-in).
2123    ///
2124    /// Ships three complementary surfaces. (1) The graph-structural
2125    /// `client-server-leak` rule: a `"use client"` file that transitively imports
2126    /// a module reading a non-public env secret through `process.env` or
2127    /// `import.meta.env`. (2) The data-driven
2128    /// `tainted-sink` catalogue: syntactic sink sites matched against a CWE
2129    /// catalogue (`security_matchers.toml`) spanning categories such as
2130    /// dangerous-html, template-escape-bypass, command-injection, code-injection,
2131    /// dynamic-regex, redos-regex, resource-amplification, dynamic-module-load,
2132    /// sql-injection, ssrf, path-traversal, header-injection, open-redirect,
2133    /// cleartext-transport, electron-unsafe-webpreferences,
2134    /// world-writable-permission, insecure-temp-file,
2135    /// mysql-multiple-statements, mass-assignment, weak-crypto,
2136    /// deprecated-cipher, insecure-randomness,
2137    /// unsafe-buffer-alloc, unsafe-deserialization, prototype-pollution,
2138    /// zip-slip, nosql-injection, ssti, xxe, xpath-injection, and
2139    /// webview-injection. (3) `hardcoded-secret`,
2140    /// an include-required
2141    /// category for provider-prefix literals and high-entropy literals assigned
2142    /// to secret-shaped identifiers. It never runs from raw entropy alone. All
2143    /// findings are CANDIDATES for verification, NOT verified vulnerabilities.
2144    /// This command is the only
2145    /// surface for security findings; they never appear under bare `fallow` or
2146    /// the `audit` gate. Build-config and test files are excluded, and public
2147    /// env prefixes such as `NEXT_PUBLIC_` and `VITE_` are treated as public.
2148    /// Honors
2149    /// `--root`, `--format {human,json,sarif}`, `--changed-since`, `--file`, `--gate`, `--diff-file`,
2150    /// `--diff-stdin`, `--workspace`, `--changed-workspaces`, `--ci`,
2151    /// `--fail-on-issues`, `--sarif-file`, `--summary`, `--explain`, and `--surface`.
2152    Security {
2153        #[command(subcommand)]
2154        subcommand: Option<SecuritySubcommand>,
2155        /// Runtime coverage input. Accepts a V8 directory, a single V8 JSON
2156        /// file, or an Istanbul coverage map JSON. When set, `fallow security`
2157        /// adds production runtime state to tainted-sink candidates and uses
2158        /// that state as an extra ranking signal. A single local capture is
2159        /// free. Continuous or multi-capture monitoring needs a license (see
2160        /// `fallow license`).
2161        #[arg(long, value_name = "PATH")]
2162        runtime_coverage: Option<PathBuf>,
2163        /// Threshold for hot-path classification, forwarded to the sidecar
2164        /// when `--runtime-coverage` is set.
2165        #[arg(long, default_value_t = 100)]
2166        min_invocations_hot: u64,
2167        /// Only report security candidates in or reachable from the specified files.
2168        /// The full project graph is still built, but output is scoped to matching
2169        /// finding anchors or trace hops. Accepts multiple values.
2170        #[arg(long, value_name = "PATH")]
2171        file: Vec<std::path::PathBuf>,
2172        /// Opt-in regression gate: fail (exit 8) only when the change introduces a
2173        /// NEW security-sink candidate in the changed lines, not on the whole
2174        /// candidate backlog. Requires a diff source: `--changed-since <ref>`,
2175        /// `--diff-file <path>`, or `--diff-stdin`. There is deliberately no `all`
2176        /// mode (gating on the full backlog is the anti-feature this gate avoids).
2177        #[arg(long, value_name = "MODE")]
2178        gate: Option<security::SecurityGateArg>,
2179        /// Include the agent-facing attack-surface inventory in JSON output.
2180        #[arg(long)]
2181        surface: bool,
2182
2183        /// Scope reported findings to this file or directory (default: whole project).
2184        /// The full project graph is still built; only reported items are narrowed.
2185        #[arg(value_name = "PATH")]
2186        path: Option<PathBuf>,
2187    },
2188
2189    /// Render a saved `--format json` results file in another format without
2190    /// re-running analysis (analyze once, then render every CI surface from
2191    /// the same file). Supports GitHub annotations/summary, CodeClimate,
2192    /// SARIF, markdown, and GitHub/GitLab PR-comment and review formats.
2193    Report {
2194        /// Path to a fallow JSON results file produced by `--format json`
2195        /// (dead-code, architecture, dupes, health, audit, security, or bare
2196        /// combined).
2197        #[arg(long, value_name = "PATH")]
2198        from: PathBuf,
2199    },
2200    /// 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
2201    Schema,
2202
2203    /// Print or vendor CI integration templates.
2204    ///
2205    /// Use `fallow ci-template gitlab` to print the GitLab CI template, or
2206    /// `fallow ci-template gitlab --vendor` to write the template plus the
2207    /// bash helper files that enable MR comments without downloading from
2208    /// raw.githubusercontent.com at pipeline runtime.
2209    CiTemplate {
2210        #[command(subcommand)]
2211        subcommand: CiTemplateCli,
2212    },
2213
2214    /// Migrate configuration from knip, jscpd, or stylelint to fallow
2215    Migrate {
2216        /// Generate `fallow.toml` instead of JSONC
2217        #[arg(long, conflicts_with = "jsonc")]
2218        toml: bool,
2219
2220        /// Write JSONC content to `.fallowrc.jsonc` instead of `.fallowrc.json`. The
2221        /// generated content is the same JSONC (with `//` comments) either way; the
2222        /// `.jsonc` extension lets editors auto-detect JSON-with-comments syntax
2223        /// highlighting and silences linters that flag comments in `.json`. Without
2224        /// `--jsonc` or `--toml`, fallow auto-mirrors the source extension: a
2225        /// `knip.jsonc` migration writes `.fallowrc.jsonc`, a `knip.json` migration
2226        /// writes `.fallowrc.json`.
2227        #[arg(long)]
2228        jsonc: bool,
2229
2230        /// Only preview the generated config without writing
2231        #[arg(long)]
2232        dry_run: bool,
2233
2234        /// Path to source config file (auto-detect if not specified)
2235        #[arg(long, value_name = "PATH")]
2236        from: Option<PathBuf>,
2237    },
2238
2239    /// Manage the license for continuous/cloud runtime monitoring.
2240    ///
2241    /// Verification is offline against an Ed25519 public key compiled into
2242    /// the binary. The license file lives at `~/.fallow/license.jwt` (or
2243    /// `$FALLOW_LICENSE_PATH`); `$FALLOW_LICENSE` env var takes precedence
2244    /// and is the recommended path for shared CI runners.
2245    License {
2246        #[command(subcommand)]
2247        subcommand: LicenseCli,
2248    },
2249
2250    /// Manage opt-in product telemetry.
2251    ///
2252    /// Telemetry is off by default. It never collects repository names, paths,
2253    /// package names, source code, config values, raw errors, or raw agent
2254    /// detection evidence. Use `fallow telemetry inspect --example` to see the
2255    /// documented payload shape, or prefix a real command with
2256    /// `FALLOW_TELEMETRY=inspect` to print the exact payload without sending.
2257    Telemetry {
2258        #[command(subcommand)]
2259        subcommand: TelemetryCli,
2260    },
2261
2262    /// Runtime coverage workflow.
2263    ///
2264    /// `setup` is the resumable first-run flow: license check, sidecar
2265    /// install, coverage recipe, then analysis.
2266    Coverage {
2267        #[command(subcommand)]
2268        subcommand: CoverageCli,
2269    },
2270
2271    /// Install or remove a Claude Code PreToolUse hook that gates
2272    /// `git commit` / `git push` on `fallow audit`, so the agent cleans
2273    /// findings before the command runs.
2274    ///
2275    /// Deprecated: use `fallow agent install` (one pass for every harness)
2276    /// or `fallow hooks install --target agent` (the gate alone). This
2277    /// command keeps working throughout fallow 3 and is removed in the next
2278    /// major. It writes into `.claude/settings.json` +
2279    /// `.claude/hooks/fallow-gate.sh`, or for Codex into `.codex/hooks.json` +
2280    /// `.codex/hooks/fallow-gate.sh` plus an `AGENTS.md` routing block. For a
2281    /// shell-level Git pre-commit hook in
2282    /// `.git/hooks/`, see `fallow hooks install --target git` instead.
2283    SetupHooks {
2284        /// Target a specific agent surface (default: auto-detect).
2285        #[arg(long, value_enum)]
2286        agent: Option<setup_hooks::HookAgentArg>,
2287
2288        /// Print what would be written or removed without touching the filesystem.
2289        #[arg(long)]
2290        dry_run: bool,
2291
2292        /// Overwrite a user-edited hook script, invalid settings.json, or
2293        /// remove a user-edited script during uninstall.
2294        #[arg(long)]
2295        force: bool,
2296
2297        /// Write to the user's home directory instead of the project root.
2298        #[arg(long)]
2299        user: bool,
2300
2301        /// Append `.claude/` to the project's `.gitignore`.
2302        #[arg(long)]
2303        gitignore_claude: bool,
2304
2305        /// Remove the fallow-gate handler, hook script, and AGENTS.md
2306        /// managed block instead of installing them. Idempotent: reports
2307        /// "unchanged" when nothing to remove.
2308        #[arg(long)]
2309        uninstall: bool,
2310    },
2311
2312    /// Generate an interactive HTML map of the codebase
2313    Viz {
2314        /// Output file path (default: fallow-viz.html in project root)
2315        #[arg(long = "out", value_name = "PATH")]
2316        output: Option<PathBuf>,
2317
2318        /// Don't open the output file in the browser
2319        #[arg(long)]
2320        no_open: bool,
2321
2322        /// Visualization output format
2323        #[arg(long = "viz-format", default_value = "html")]
2324        viz_format: viz::VizFormat,
2325    },
2326}
2327
2328#[derive(Subcommand)]
2329enum SecuritySubcommand {
2330    /// Render verifier-retained survivor candidates from fallow output plus verifier verdicts.
2331    Survivors {
2332        /// Raw `fallow security --format json` candidate output.
2333        #[arg(long, value_name = "PATH")]
2334        candidates: PathBuf,
2335        /// Verifier verdict JSON file.
2336        #[arg(long, value_name = "PATH")]
2337        verdicts: PathBuf,
2338        /// Fail when any candidate has no matching verdict.
2339        #[arg(long)]
2340        require_verdict_for_each_candidate: bool,
2341    },
2342    /// Group unresolved security callees into actionable blind-spot output.
2343    #[command(name = "blind-spots")]
2344    BlindSpots {
2345        /// Scope diagnostics to selected files.
2346        #[arg(long, value_name = "PATH")]
2347        file: Vec<PathBuf>,
2348    },
2349}
2350
2351#[derive(clap::Subcommand)]
2352enum BaselinesCli {
2353    /// Remove the baseline entries that match no current finding.
2354    ///
2355    /// Reads the files that `audit.deadCodeBaseline`, `audit.healthBaseline`
2356    /// and `audit.dupesBaseline` name, runs one whole-project analysis, and
2357    /// removes each entry that `--baseline` would no longer match. Prune never
2358    /// adds an entry: a new finding stays outside the baseline, and the normal
2359    /// gates report it. To record new findings on purpose, use
2360    /// `fallow <dead-code|health|dupes> --save-baseline`.
2361    ///
2362    /// Exit codes: 0 when the files are pruned or have nothing to prune, 1 with
2363    /// `--check` when an entry can be pruned, and 2 when a file cannot be read
2364    /// or pruned. A file with an older key form is skipped with the command
2365    /// that saves it again.
2366    Prune {
2367        /// Report the entries that a prune would remove, and write nothing.
2368        #[arg(long)]
2369        check: bool,
2370
2371        /// The dead-code baseline to prune. Overrides `audit.deadCodeBaseline`.
2372        #[arg(long, value_name = "PATH")]
2373        dead_code_baseline: Option<PathBuf>,
2374
2375        /// The health baseline to prune. Overrides `audit.healthBaseline`.
2376        #[arg(long, value_name = "PATH")]
2377        health_baseline: Option<PathBuf>,
2378
2379        /// The duplication baseline to prune. Overrides `audit.dupesBaseline`.
2380        #[arg(long, value_name = "PATH")]
2381        dupes_baseline: Option<PathBuf>,
2382
2383        /// The coverage input for CRAP scores, as for `fallow health
2384        /// --coverage`. Use the input that the health baseline was saved with.
2385        #[arg(long, value_name = "PATH")]
2386        coverage: Option<PathBuf>,
2387
2388        /// Rebase the file paths of the coverage input onto this root.
2389        #[arg(long, value_name = "PATH")]
2390        coverage_root: Option<PathBuf>,
2391    },
2392}
2393
2394#[derive(clap::Subcommand)]
2395enum AuditCacheCli {
2396    /// Remove reusable audit caches owned by an explicit project root.
2397    ///
2398    /// Deletes this project's cache entries unconditionally, warm or not. To
2399    /// apply the age-based GC policy across every cache entry instead, use
2400    /// `fallow audit-cache prune`.
2401    Remove {
2402        /// Print what would be removed without touching the filesystem.
2403        #[arg(long)]
2404        dry_run: bool,
2405
2406        /// Confirm removal in non-interactive environments.
2407        #[arg(long, alias = "force")]
2408        yes: bool,
2409    },
2410
2411    /// Apply the audit cache GC policy now and report every entry.
2412    ///
2413    /// Runs the same reclaim policy every `fallow audit` run already applies
2414    /// silently: orphaned-sidecar cleanup, age-based reclaim under the
2415    /// resolved threshold, and cross-repo reclaim of abandoned entries whose
2416    /// recorded owner root no longer exists. Entries owned by other live
2417    /// projects are never touched. Use `--dry-run` to preview every decision
2418    /// the policy would take without touching the filesystem. `--root` is
2419    /// optional and defaults to the current directory. Reported sizes come
2420    /// from a full recursive walk of each cache entry, which can take a few
2421    /// seconds on large caches. To delete one project's caches
2422    /// unconditionally, use `fallow audit-cache remove --root <path> --yes`.
2423    Prune {
2424        /// Preview decisions without touching the filesystem.
2425        #[arg(long)]
2426        dry_run: bool,
2427
2428        /// Age threshold in days for this invocation. Overrides
2429        /// FALLOW_AUDIT_CACHE_MAX_AGE_DAYS and the `audit.cacheMaxAgeDays`
2430        /// config field (default 30). `0` disables age-based reclaim but
2431        /// still reclaims orphaned sidecars and entries whose recorded owner
2432        /// root is gone; unconditional deletion of one project's caches is
2433        /// `fallow audit-cache remove --root <path> --yes`.
2434        #[arg(long, value_name = "N")]
2435        max_age_days: Option<u32>,
2436    },
2437}
2438
2439#[derive(clap::Subcommand)]
2440enum LicenseCli {
2441    /// Activate a license JWT.
2442    ///
2443    /// JWT input precedence: positional arg > `--from-file` > stdin (`-`).
2444    /// All paths normalize whitespace before crypto verification.
2445    Activate {
2446        /// JWT as a positional argument.
2447        #[arg(value_name = "JWT")]
2448        jwt: Option<String>,
2449
2450        /// Path to a file containing the JWT.
2451        #[arg(long, value_name = "PATH")]
2452        from_file: Option<PathBuf>,
2453
2454        /// Read JWT from stdin.
2455        #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2456        stdin: bool,
2457
2458        /// Start a 30-day email-gated trial in one step.
2459        ///
2460        /// The trial endpoint is rate-limited to 5 requests per hour per IP.
2461        /// In CI or behind a shared NAT, start the trial from a developer
2462        /// machine and set FALLOW_LICENSE (or FALLOW_LICENSE_PATH) on the
2463        /// runner instead of re-running `activate --trial` per job.
2464        #[arg(long, requires = "email")]
2465        trial: bool,
2466
2467        /// Email address for the trial flow.
2468        #[arg(long, value_name = "ADDR")]
2469        email: Option<String>,
2470    },
2471    /// Show the active license tier, seats, features, and days remaining.
2472    Status,
2473    /// Fetch a fresh JWT from `api.fallow.cloud` (network-only).
2474    ///
2475    /// The stored license JWT is the primary identity proof. When it is
2476    /// missing, or the cloud reports it as too stale to exchange, the request
2477    /// is retried with a full-access API key.
2478    Refresh {
2479        /// Fallow Cloud API key (bearer token) used when the stored license
2480        /// JWT cannot be refreshed.
2481        ///
2482        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
2483        /// <https://fallow.cloud/settings#api-keys>.
2484        ///
2485        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
2486        /// secret on the command line may be visible to other processes via
2487        /// `ps` and can leak into shell history or process audit logs.
2488        #[arg(long, value_name = "KEY")]
2489        api_key: Option<String>,
2490    },
2491    /// Remove the local license file.
2492    Deactivate,
2493}
2494
2495#[derive(Clone, Copy, clap::Subcommand)]
2496enum TelemetryCli {
2497    /// Show effective telemetry state, precedence, and controls.
2498    Status,
2499    /// Enable opt-in telemetry in the user-level fallow config.
2500    Enable,
2501    /// Disable telemetry in the user-level fallow config.
2502    Disable,
2503    /// Explain inspect mode or print example payloads.
2504    Inspect {
2505        /// Print documented example payloads and field purposes.
2506        #[arg(long)]
2507        example: bool,
2508    },
2509}
2510
2511#[derive(clap::Subcommand)]
2512enum CiTemplateCli {
2513    /// Print or vendor the GitLab CI template and MR integration helpers.
2514    Gitlab {
2515        /// Write ci/ and action/ helper files under DIR instead of printing the template.
2516        ///
2517        /// Passing --vendor without a DIR writes into the current directory.
2518        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2519        vendor: Option<PathBuf>,
2520
2521        /// Overwrite existing files that differ from the bundled template.
2522        #[arg(long)]
2523        force: bool,
2524    },
2525}
2526
2527#[derive(clap::Subcommand)]
2528enum CoverageCli {
2529    /// Resumable first-run setup: license + sidecar + recipe + analysis.
2530    Setup {
2531        /// Accept all prompts automatically.
2532        #[arg(short = 'y', long)]
2533        yes: bool,
2534
2535        /// Print instructions instead of prompting.
2536        #[arg(long)]
2537        non_interactive: bool,
2538
2539        /// Emit deterministic setup instructions as JSON. Implies --non-interactive.
2540        #[arg(long)]
2541        json: bool,
2542    },
2543    /// Analyze runtime coverage from a local artifact or explicit cloud source.
2544    ///
2545    /// Cloud mode is opt-in only. `FALLOW_API_KEY` by itself never selects
2546    /// cloud mode; pass `--cloud` / `--runtime-coverage-cloud`, or set
2547    /// `FALLOW_RUNTIME_COVERAGE_SOURCE=cloud`.
2548    Analyze {
2549        /// File or directory containing local runtime coverage input. A
2550        /// single local capture is free. Continuous or multi-capture
2551        /// monitoring needs a license (see `fallow license`).
2552        #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2553        runtime_coverage: Option<PathBuf>,
2554
2555        /// Fetch latest runtime facts from Fallow Cloud for the selected repo.
2556        #[arg(long, visible_alias = "runtime-coverage-cloud")]
2557        cloud: bool,
2558
2559        /// Fallow Cloud API key. Precedence: this flag > $FALLOW_API_KEY.
2560        #[arg(long, value_name = "KEY")]
2561        api_key: Option<String>,
2562
2563        /// Override the Fallow Cloud base URL.
2564        #[arg(long, value_name = "URL")]
2565        api_endpoint: Option<String>,
2566
2567        /// Repository identifier, for example `owner/repo`.
2568        ///
2569        /// Defaults to $FALLOW_REPO, then the parsed origin URL from
2570        /// `git remote get-url origin`. Slashes are percent-encoded as one
2571        /// URL segment when calling the cloud runtime-context endpoint.
2572        #[arg(long, value_name = "OWNER/REPO")]
2573        repo: Option<String>,
2574
2575        /// Optional monorepo/project disambiguator.
2576        #[arg(long, value_name = "ID")]
2577        project_id: Option<String>,
2578
2579        /// Runtime observation window to request from cloud (1..=90 days).
2580        #[arg(long, value_name = "DAYS", default_value_t = 30)]
2581        coverage_period: u16,
2582
2583        /// Optional runtime environment filter.
2584        #[arg(long, value_name = "ENV")]
2585        environment: Option<String>,
2586
2587        /// Optional commit SHA filter for cloud runtime facts.
2588        #[arg(long, value_name = "SHA")]
2589        commit_sha: Option<String>,
2590
2591        /// Analyze production code only.
2592        #[arg(long)]
2593        production: bool,
2594
2595        /// Threshold for hot-path classification.
2596        #[arg(long, default_value_t = 100)]
2597        min_invocations_hot: u64,
2598
2599        /// Minimum total trace volume before high-confidence verdicts.
2600        #[arg(long, value_name = "N")]
2601        min_observation_volume: Option<u32>,
2602
2603        /// Fraction of total trace count below which an invoked function is low traffic.
2604        #[arg(long, value_name = "RATIO")]
2605        low_traffic_threshold: Option<f64>,
2606
2607        /// Show only the top N runtime findings and hot paths.
2608        #[arg(long)]
2609        top: Option<usize>,
2610
2611        /// Show the first-class blast-radius section in human output.
2612        #[arg(long)]
2613        blast_radius: bool,
2614
2615        /// Show the first-class importance section in human output.
2616        #[arg(long)]
2617        importance: bool,
2618
2619        /// List cloud runtime functions with no local counterpart on stderr.
2620        #[arg(long)]
2621        debug_unmatched: bool,
2622    },
2623    /// Upload a static function inventory to Fallow Cloud. Needs a fallow
2624    /// cloud API key. Unlocks the `untracked` filter on the dashboard by
2625    /// pairing runtime coverage data with the AST view of "every function
2626    /// that exists". See <https://fallow.tools/docs/analysis/runtime-coverage/>.
2627    ///
2628    /// This command makes network calls to Fallow Cloud. `fallow dead-code`
2629    /// stays offline.
2630    ///
2631    /// Exit codes: 0 ok · 7 network · 10 validation · 11 payload too large
2632    /// · 12 auth rejected · 13 server error.
2633    UploadInventory {
2634        /// Fallow Cloud API key (bearer token).
2635        ///
2636        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
2637        /// <https://fallow.cloud/settings#api-keys>.
2638        ///
2639        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
2640        /// secret on the command line may be visible to other processes via
2641        /// `ps` and can leak into shell history or process audit logs.
2642        #[arg(long, value_name = "KEY")]
2643        api_key: Option<String>,
2644
2645        /// Override the Fallow Cloud base URL.
2646        ///
2647        /// Useful for staging and on-premise deployments. Also respects
2648        /// $FALLOW_API_URL when this flag is not set.
2649        #[arg(long, value_name = "URL")]
2650        api_endpoint: Option<String>,
2651
2652        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
2653        ///
2654        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
2655        /// parsed origin URL from `git remote get-url origin`.
2656        #[arg(long, value_name = "PROJECT_ID")]
2657        project_id: Option<String>,
2658
2659        /// Explicit git SHA for this inventory.
2660        ///
2661        /// Default: `git rev-parse HEAD`. The inventory is keyed on this
2662        /// value; the cloud back-fills hourly buckets with a matching SHA.
2663        #[arg(long, value_name = "SHA")]
2664        git_sha: Option<String>,
2665
2666        /// Proceed even when the working tree has uncommitted changes.
2667        ///
2668        /// Warning: the inventory is generated from the working copy, so it
2669        /// may not match the uploaded git SHA. Commit or stash first if you
2670        /// want a SHA-exact upload.
2671        #[arg(long)]
2672        allow_dirty: bool,
2673
2674        /// Additional glob patterns to exclude from the walk.
2675        ///
2676        /// Applied after the existing fallow ignore rules. Repeatable.
2677        #[arg(long, value_name = "GLOB", num_args = 0..)]
2678        exclude_paths: Vec<String>,
2679
2680        /// Prefix prepended to every emitted filePath so the static
2681        /// inventory joins with the runtime beacon for your deployment.
2682        /// Required for containerized deployments where the deployed
2683        /// WORKDIR rebases paths at runtime. Default: none (paths emit
2684        /// repo-relative, matching local runs and non-container CI).
2685        ///
2686        /// Common values: `/app` (typical Dockerfile), `/workspace`
2687        /// (Buildpacks / Cloud Run), `/usr/src/app` (older Node images),
2688        /// `/var/task` (Lambda), `/home/runner/work/<repo>/<repo>`
2689        /// (GitHub Actions default checkout).
2690        ///
2691        /// Must start with `/` and use POSIX separators.
2692        #[arg(long, value_name = "PREFIX")]
2693        path_prefix: Option<String>,
2694
2695        /// Print what would be uploaded and exit. No network call.
2696        #[arg(long)]
2697        dry_run: bool,
2698
2699        /// Also upload importer edges (which files import each function) so the
2700        /// cloud can show change-time blast radius. Opt-in: this builds the
2701        /// import graph by running the full static analysis, whereas the default
2702        /// upload is a fast per-file walk. The graph is cached, so a CI step that
2703        /// already ran analysis pays little extra.
2704        #[arg(long)]
2705        with_callers: bool,
2706
2707        /// Treat transient upload failures as warnings instead of errors
2708        /// (exit 0). Validation and auth errors still fail hard; this only
2709        /// downgrades transport and server errors.
2710        #[arg(long)]
2711        ignore_upload_errors: bool,
2712    },
2713    /// Upload JavaScript source maps to Fallow Cloud for bundled runtime coverage.
2714    ///
2715    /// Scans a build output directory for `.map` files and uploads them under
2716    /// the selected repo + git SHA. The production beacon reports bundled
2717    /// paths; the cloud resolver uses these maps to remap runtime coverage back
2718    /// to original source files.
2719    ///
2720    /// Each upload also carries the map's path relative to the repo root, so the
2721    /// source-evidence viewer can resolve a monorepo sub-package map's relative
2722    /// `sources[]` (e.g. `../../src/X`) to the package-prefixed source path
2723    /// (e.g. `dashboard/src/X`). Run from the repo root so this prefix is
2724    /// correct.
2725    UploadSourceMaps {
2726        /// Directory to scan recursively for source maps.
2727        #[arg(long, value_name = "PATH", default_value = "dist")]
2728        dir: PathBuf,
2729
2730        /// Glob pattern, relative to --dir, selecting maps to upload.
2731        #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2732        include: String,
2733
2734        /// Glob pattern, relative to --dir, selecting files to skip.
2735        ///
2736        /// Repeatable. Defaults to `**/node_modules/**`.
2737        #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2738        exclude: Vec<String>,
2739
2740        /// Repo name used in the API path.
2741        ///
2742        /// Defaults to package.json repository.url, then `git remote get-url origin`.
2743        #[arg(long, value_name = "NAME")]
2744        repo: Option<String>,
2745
2746        /// Commit SHA to key uploads under.
2747        ///
2748        /// Defaults to $GITHUB_SHA, $CI_COMMIT_SHA, $COMMIT_SHA, then
2749        /// `git rev-parse HEAD`.
2750        #[arg(long, value_name = "SHA")]
2751        git_sha: Option<String>,
2752
2753        /// Override the Fallow Cloud base URL.
2754        #[arg(long, value_name = "URL")]
2755        endpoint: Option<String>,
2756
2757        /// Send only the basename as fileName by default.
2758        ///
2759        /// Use `--strip-path=false` when your runtime coverage reports bundle
2760        /// paths relative to the build directory, such as `assets/app.js`.
2761        /// When two maps share a basename, each of them is sent under its path
2762        /// in the build directory, so that no map replaces another.
2763        #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2764        strip_path: bool,
2765
2766        /// Print what would be uploaded and exit. No network call.
2767        #[arg(long)]
2768        dry_run: bool,
2769
2770        /// Parallel upload fanout.
2771        #[arg(long, value_name = "N", default_value_t = 4)]
2772        concurrency: usize,
2773
2774        /// Stop on first upload error.
2775        #[arg(long)]
2776        fail_fast: bool,
2777    },
2778    /// Upload static dead-code findings to Fallow Cloud for the source-evidence viewer.
2779    ///
2780    /// Runs fallow's static analysis and uploads the `unused_export` and
2781    /// `dead_file` verdicts under the selected repo + git SHA. The cloud
2782    /// overlays them on the source view alongside the runtime coverage overlay.
2783    /// Findings are replace-by-SHA: each run sends the complete set for the SHA.
2784    UploadStaticFindings {
2785        /// Fallow Cloud API key (bearer token).
2786        ///
2787        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
2788        /// <https://fallow.cloud/settings#api-keys>. This must be a live API
2789        /// key, not a publishable ingest key.
2790        ///
2791        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
2792        /// secret on the command line may be visible to other processes via
2793        /// `ps` and can leak into shell history or process audit logs.
2794        #[arg(long, value_name = "KEY")]
2795        api_key: Option<String>,
2796
2797        /// Override the Fallow Cloud base URL.
2798        ///
2799        /// Useful for staging and on-premise deployments. Also respects
2800        /// $FALLOW_API_URL when this flag is not set.
2801        #[arg(long, value_name = "URL")]
2802        api_endpoint: Option<String>,
2803
2804        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
2805        ///
2806        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
2807        /// parsed origin URL from `git remote get-url origin`.
2808        #[arg(long, value_name = "PROJECT_ID")]
2809        project_id: Option<String>,
2810
2811        /// Explicit git SHA for these findings.
2812        ///
2813        /// Default: `git rev-parse HEAD`. Findings are keyed on this value and
2814        /// fully replace any prior set uploaded for the same SHA.
2815        #[arg(long, value_name = "SHA")]
2816        git_sha: Option<String>,
2817
2818        /// Proceed even when the working tree has uncommitted changes.
2819        ///
2820        /// Warning: findings are generated from the working copy, so they may
2821        /// not match the uploaded git SHA. Commit or stash first if you want a
2822        /// SHA-exact upload.
2823        #[arg(long)]
2824        allow_dirty: bool,
2825
2826        /// Print what would be uploaded and exit. No network call.
2827        #[arg(long)]
2828        dry_run: bool,
2829
2830        /// Treat transient upload failures as warnings instead of errors
2831        /// (exit 0). Validation and auth errors still fail hard; this only
2832        /// downgrades transport and server errors.
2833        #[arg(long)]
2834        ignore_upload_errors: bool,
2835    },
2836    /// Read the production facts of changed code from Fallow Cloud.
2837    ///
2838    /// Sends the changed files or functions to the cloud review-packet
2839    /// endpoint and prints the answer as JSON: per function the production
2840    /// calls, the tracking state and the evidence window. It runs no local
2841    /// analysis and pulls no full runtime context, so the answer stays small.
2842    /// Without --file and --function, the files changed against the base are
2843    /// sent. The base resolves like `fallow audit`: `--base` (alias of
2844    /// `--changed-since`), then $FALLOW_AUDIT_BASE, then the merge-base with
2845    /// the upstream or the remote default branch.
2846    ReviewPacket {
2847        /// Fallow Cloud API key. Precedence: this flag > $FALLOW_API_KEY.
2848        #[arg(long, value_name = "KEY")]
2849        api_key: Option<String>,
2850
2851        /// Override the Fallow Cloud base URL.
2852        #[arg(long, value_name = "URL")]
2853        api_endpoint: Option<String>,
2854
2855        /// Repository identifier, for example `owner/repo`.
2856        ///
2857        /// Defaults to $FALLOW_REPO, then the parsed origin URL from
2858        /// `git remote get-url origin`.
2859        #[arg(long, value_name = "OWNER/REPO")]
2860        repo: Option<String>,
2861
2862        /// Optional monorepo/project disambiguator.
2863        #[arg(long, value_name = "ID")]
2864        project_id: Option<String>,
2865
2866        /// Runtime observation window (1..=90 days). The cloud default is 30.
2867        #[arg(long, value_name = "DAYS")]
2868        coverage_period: Option<u16>,
2869
2870        /// Optional commit SHA of the deployment to read.
2871        #[arg(long, value_name = "SHA")]
2872        commit_sha: Option<String>,
2873
2874        /// Repo-relative file to include. Repeatable.
2875        #[arg(long = "file", value_name = "PATH")]
2876        files: Vec<String>,
2877
2878        /// Function to include, as FILE:NAME or FILE:NAME:LINE. Repeatable.
2879        #[arg(long = "function", value_name = "FILE:NAME[:LINE]")]
2880        functions: Vec<String>,
2881    },
2882    /// Read how production behavior changed between two deployments.
2883    ///
2884    /// Prints the cloud deployment change report as JSON: per function the
2885    /// change kind (new_called, new_not_called, heated_up, cooled_down,
2886    /// stopped, unchanged) between the deployment and its base. Pass the
2887    /// base deployment commit with `--base <sha>`; without it, the cloud
2888    /// picks the previous deployment with production runtime.
2889    DeploymentChanges {
2890        /// Fallow Cloud API key. Precedence: this flag > $FALLOW_API_KEY.
2891        #[arg(long, value_name = "KEY")]
2892        api_key: Option<String>,
2893
2894        /// Override the Fallow Cloud base URL.
2895        #[arg(long, value_name = "URL")]
2896        api_endpoint: Option<String>,
2897
2898        /// Repository identifier, for example `owner/repo`.
2899        ///
2900        /// Defaults to $FALLOW_REPO, then the parsed origin URL from
2901        /// `git remote get-url origin`.
2902        #[arg(long, value_name = "OWNER/REPO")]
2903        repo: Option<String>,
2904
2905        /// Commit SHA of the deployment. Default: `git rev-parse HEAD`.
2906        #[arg(long, value_name = "SHA")]
2907        sha: Option<String>,
2908
2909        /// Show only one change kind.
2910        #[arg(long, value_name = "KIND", value_parser = [
2911            "stopped", "new_not_called", "heated_up", "cooled_down", "new_called", "unchanged",
2912        ])]
2913        change: Option<String>,
2914
2915        /// Page size (1..=200).
2916        #[arg(long, value_name = "N")]
2917        limit: Option<u16>,
2918
2919        /// Cursor from `meta.cursor` of the previous page.
2920        #[arg(long, value_name = "CURSOR")]
2921        cursor: Option<String>,
2922    },
2923}
2924
2925#[derive(Subcommand)]
2926enum CiCli {
2927    /// Compute the provider action for a rendered sticky PR summary comment.
2928    PlanPrComment {
2929        /// Path to the rendered PR comment Markdown body.
2930        #[arg(long)]
2931        body: PathBuf,
2932
2933        /// Sticky marker id used in the rendered body.
2934        #[arg(long)]
2935        marker_id: String,
2936
2937        /// Treat the rendered body as a clean no-findings result.
2938        #[arg(long)]
2939        clean: bool,
2940
2941        /// Existing provider comment id, when a matching sticky comment exists.
2942        #[arg(long)]
2943        existing_comment_id: Option<String>,
2944
2945        /// Path to the existing provider comment body. Enables unchanged-skip planning.
2946        #[arg(long)]
2947        existing_body: Option<PathBuf>,
2948    },
2949
2950    /// Post, update, or skip a rendered sticky PR summary comment.
2951    PostPrComment {
2952        /// Provider whose PR comment is being posted.
2953        #[arg(long, value_enum)]
2954        provider: CiProviderArg,
2955
2956        /// Pull request number (GitHub).
2957        #[arg(long)]
2958        pr: Option<String>,
2959
2960        /// Merge request IID (GitLab).
2961        #[arg(long)]
2962        mr: Option<String>,
2963
2964        /// Path to the rendered PR comment Markdown body.
2965        #[arg(long)]
2966        body: PathBuf,
2967
2968        /// Path to the typed PR comment envelope JSON, when available.
2969        #[arg(long)]
2970        envelope: Option<PathBuf>,
2971
2972        /// Sticky marker id used in the rendered body.
2973        #[arg(long)]
2974        marker_id: String,
2975
2976        /// Treat the rendered body as a clean no-findings result.
2977        #[arg(long)]
2978        clean: bool,
2979
2980        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2981        #[arg(long)]
2982        repo: Option<String>,
2983
2984        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2985        #[arg(long = "project-id")]
2986        project_id: Option<String>,
2987
2988        /// Provider API base URL. Defaults to github.com.
2989        #[arg(long = "api-url")]
2990        api_url: Option<String>,
2991
2992        /// Compute the post plan without creating or updating the provider comment.
2993        #[arg(long)]
2994        dry_run: bool,
2995    },
2996
2997    /// Post a rendered review envelope as a provider review or summary comment.
2998    PostReview {
2999        /// Provider whose review envelope is being posted.
3000        #[arg(long, value_enum)]
3001        provider: CiProviderArg,
3002
3003        /// Pull request number (GitHub).
3004        #[arg(long)]
3005        pr: Option<String>,
3006
3007        /// Merge request IID (GitLab).
3008        #[arg(long)]
3009        mr: Option<String>,
3010
3011        /// Path to a review-github or review-gitlab JSON envelope.
3012        #[arg(long)]
3013        envelope: PathBuf,
3014
3015        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
3016        #[arg(long)]
3017        repo: Option<String>,
3018
3019        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
3020        #[arg(long = "project-id")]
3021        project_id: Option<String>,
3022
3023        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
3024        #[arg(long = "api-url")]
3025        api_url: Option<String>,
3026
3027        /// Compute the post plan without creating provider comments.
3028        #[arg(long)]
3029        dry_run: bool,
3030    },
3031
3032    /// Post a GitHub Check Run from a typed PR decision surface.
3033    PostCheckRun {
3034        /// Provider whose check run is being posted. Only GitHub is supported.
3035        #[arg(long, value_enum)]
3036        provider: CiProviderArg,
3037
3038        /// Path to a fallow-pr-decision JSON sidecar.
3039        #[arg(long)]
3040        decision: PathBuf,
3041
3042        /// GitHub repository in owner/name form.
3043        #[arg(long)]
3044        repo: String,
3045
3046        /// Head SHA the check run should attach to.
3047        #[arg(long = "head-sha")]
3048        head_sha: String,
3049
3050        /// Provider API base URL. Defaults to github.com.
3051        #[arg(long = "api-url")]
3052        api_url: Option<String>,
3053
3054        /// Post one check run per decision gate instead of one aggregate check.
3055        #[arg(long = "split-gates")]
3056        split_gates: bool,
3057
3058        /// Print the check run payload without posting it.
3059        #[arg(long)]
3060        dry_run: bool,
3061    },
3062
3063    /// Validate a rendered review envelope and compute a stable reconcile plan.
3064    ReconcileReview {
3065        /// Provider whose review envelope is being reconciled.
3066        #[arg(long, value_enum)]
3067        provider: CiProviderArg,
3068
3069        /// Pull request number (GitHub).
3070        #[arg(long)]
3071        pr: Option<String>,
3072
3073        /// Merge request IID (GitLab).
3074        #[arg(long)]
3075        mr: Option<String>,
3076
3077        /// Path to a review-github or review-gitlab JSON envelope.
3078        #[arg(long)]
3079        envelope: PathBuf,
3080
3081        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
3082        #[arg(long)]
3083        repo: Option<String>,
3084
3085        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
3086        #[arg(long = "project-id")]
3087        project_id: Option<String>,
3088
3089        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
3090        #[arg(long = "api-url")]
3091        api_url: Option<String>,
3092
3093        /// Compute the reconcile plan without posting resolution notes or resolving threads.
3094        #[arg(long)]
3095        dry_run: bool,
3096    },
3097}
3098
3099#[derive(Subcommand)]
3100enum RulePackCli {
3101    /// Scaffold a new rule pack file and wire it into the config
3102    Init {
3103        /// Pack name (default: the template name, or "team-policy")
3104        name: Option<String>,
3105
3106        /// Template: starter, ai-safe-repo, side-effect-free-domain, clean-architecture, next-app-router
3107        #[arg(long, default_value = "starter")]
3108        template: String,
3109
3110        /// Directory for the pack file, relative to the project root
3111        #[arg(long, default_value = "rule-packs")]
3112        dir: String,
3113
3114        /// Only write the pack file; do not modify the config
3115        #[arg(long)]
3116        no_config: bool,
3117    },
3118
3119    /// List configured rule packs and their rules
3120    List,
3121
3122    /// Evaluate a pack (or all configured packs) against this project and print matches
3123    Test {
3124        /// Path to a pack file to test in isolation (default: all configured packs)
3125        pack: Option<PathBuf>,
3126    },
3127
3128    /// Print the JSON Schema for rule pack files
3129    Schema,
3130}
3131
3132/// CLI mirror of [`fallow_engine::baseline::HealthBaselineMode`].
3133#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
3134pub enum BaselineModeArg {
3135    /// Match a saved health baseline per file and finding category.
3136    #[default]
3137    Count,
3138    /// Match a saved health baseline per function identity and finding
3139    /// category, so a hotspot that replaces another hotspot is reported.
3140    Identity,
3141}
3142
3143impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
3144    fn from(value: BaselineModeArg) -> Self {
3145        match value {
3146            BaselineModeArg::Count => Self::Count,
3147            BaselineModeArg::Identity => Self::Identity,
3148        }
3149    }
3150}
3151
3152#[derive(Clone, Copy, Debug, clap::ValueEnum)]
3153enum CiProviderArg {
3154    Github,
3155    Gitlab,
3156}
3157
3158/// CLI mirror of [`fallow_config::TypeAwareRequire`].
3159#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3160enum TypeAwareRequireArg {
3161    /// Keep conservative findings and report semantic gaps.
3162    BestEffort,
3163    /// Fail the quality gate when a requested semantic query is incomplete.
3164    Complete,
3165}
3166
3167impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
3168    fn from(value: TypeAwareRequireArg) -> Self {
3169        match value {
3170            TypeAwareRequireArg::BestEffort => Self::BestEffort,
3171            TypeAwareRequireArg::Complete => Self::Complete,
3172        }
3173    }
3174}
3175
3176/// Filter refactoring targets by effort level.
3177#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3178pub enum EffortFilter {
3179    Low,
3180    Medium,
3181    High,
3182}
3183
3184impl EffortFilter {
3185    /// Convert to the corresponding `EffortEstimate` for comparison.
3186    const fn to_estimate(self) -> fallow_output::EffortEstimate {
3187        match self {
3188            Self::Low => fallow_output::EffortEstimate::Low,
3189            Self::Medium => fallow_output::EffortEstimate::Medium,
3190            Self::High => fallow_output::EffortEstimate::High,
3191        }
3192    }
3193}
3194
3195/// CLI parser for the health severity gate.
3196#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3197pub enum HealthSeverityCli {
3198    Moderate,
3199    High,
3200    Critical,
3201}
3202
3203impl HealthSeverityCli {
3204    /// Convert to the typed health output severity.
3205    const fn to_health_severity(self) -> fallow_output::FindingSeverity {
3206        match self {
3207            Self::Moderate => fallow_output::FindingSeverity::Moderate,
3208            Self::High => fallow_output::FindingSeverity::High,
3209            Self::Critical => fallow_output::FindingSeverity::Critical,
3210        }
3211    }
3212}
3213
3214/// Privacy mode for author emails emitted by `--ownership`.
3215///
3216/// CLI mirror of [`fallow_config::EmailMode`]. Kept as a separate enum so
3217/// the help text controls rendering and we don't leak config-internal
3218/// schema details into clap.
3219#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3220pub enum EmailModeArg {
3221    /// Show full email addresses as recorded in git history.
3222    Raw,
3223    /// Show local-part only (default). Unwraps GitHub-style noreply prefixes.
3224    Handle,
3225    /// Show stable non-cryptographic pseudonyms (`xxh3:<hex>`).
3226    Anonymized,
3227    /// Legacy spelling for anonymized output.
3228    #[value(hide = true)]
3229    Hash,
3230}
3231
3232impl EmailModeArg {
3233    /// Convert to the equivalent config-level mode.
3234    const fn to_config(self) -> fallow_config::EmailMode {
3235        match self {
3236            Self::Raw => fallow_config::EmailMode::Raw,
3237            Self::Handle => fallow_config::EmailMode::Handle,
3238            Self::Anonymized => fallow_config::EmailMode::Anonymized,
3239            Self::Hash => fallow_config::EmailMode::Hash,
3240        }
3241    }
3242}
3243
3244/// CLI mirror of [`fallow_config::AuditGate`].
3245#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3246pub enum AuditGateArg {
3247    /// Only findings introduced by the current changeset affect the verdict.
3248    NewOnly,
3249    /// All findings in changed files affect the verdict.
3250    All,
3251}
3252
3253impl From<AuditGateArg> for fallow_config::AuditGate {
3254    fn from(value: AuditGateArg) -> Self {
3255        match value {
3256            AuditGateArg::NewOnly => Self::NewOnly,
3257            AuditGateArg::All => Self::All,
3258        }
3259    }
3260}
3261
3262/// Parse `--min-occurrences` and reject values below 2. A single occurrence
3263/// is not a duplicate; silently clamping would diverge from the config-file
3264/// validator, which also rejects `< 2`.
3265fn parse_min_occurrences(s: &str) -> Result<usize, String> {
3266    let value: usize = s
3267        .parse()
3268        .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
3269    if value < 2 {
3270        return Err(format!(
3271            "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
3272        ));
3273    }
3274    Ok(value)
3275}
3276
3277/// Resolve an audit baseline path using CLI > config precedence.
3278///
3279/// Both sources resolve relative paths against the project root. This keeps
3280/// behavior consistent in CI scripts where `--root $REPO_ROOT` differs from
3281/// the process CWD.
3282fn resolve_audit_baseline_path(
3283    root: &std::path::Path,
3284    cli: Option<&std::path::Path>,
3285    config: Option<&str>,
3286) -> Option<PathBuf> {
3287    let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
3288        config.map(|p| {
3289            let path = PathBuf::from(p);
3290            if path_util::is_absolute_path_any_platform(&path) {
3291                path
3292            } else {
3293                root.join(path)
3294            }
3295        })
3296    })?;
3297    if path_util::is_absolute_path_any_platform(&path) {
3298        Some(path)
3299    } else {
3300        Some(root.join(path))
3301    }
3302}
3303
3304fn emit_known_failure(
3305    message: &str,
3306    exit_code: u8,
3307    output: fallow_config::OutputFormat,
3308    reason: telemetry::FailureReason,
3309) -> ExitCode {
3310    telemetry::note_failure_reason(reason);
3311    emit_error(message, exit_code, output)
3312}
3313
3314fn emit_known_failure_with_style(
3315    message: &str,
3316    exit_code: u8,
3317    output: fallow_config::OutputFormat,
3318    json_style: json_style::JsonStyle,
3319    reason: telemetry::FailureReason,
3320) -> ExitCode {
3321    telemetry::note_failure_reason(reason);
3322    error::emit_error_with_style(message, exit_code, output, json_style)
3323}
3324
3325fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
3326    if cli.baseline.is_some() {
3327        Some("--baseline")
3328    } else if cli.save_baseline.is_some() {
3329        Some("--save-baseline")
3330    } else if cli.fail_on_stale_baseline {
3331        Some("--fail-on-stale-baseline")
3332    } else if cli.fail_on_baseline_growth {
3333        Some("--fail-on-baseline-growth")
3334    } else if cli.baseline_base.is_some() {
3335        Some("--baseline-base")
3336    } else if cli.fail_on_parse_error {
3337        Some("--fail-on-parse-error")
3338    } else if cli.production {
3339        Some("--production")
3340    } else if cli.no_production {
3341        Some("--no-production")
3342    } else if cli.no_package_baselines {
3343        Some("--no-package-baselines")
3344    } else if cli.group_by.is_some() {
3345        Some("--group-by")
3346    } else if cli.performance {
3347        Some("--performance")
3348    } else if cli.explain_skipped {
3349        Some("--explain-skipped")
3350    } else if cli.fail_on_regression {
3351        Some("--fail-on-regression")
3352    } else if cli.regression_baseline.is_some() {
3353        Some("--regression-baseline")
3354    } else if cli.save_regression_baseline.is_some() {
3355        Some("--save-regression-baseline")
3356    } else if let Some(flag) = cli.dupes_overrides().first_global_flag() {
3357        Some(flag)
3358    } else if cli.include_entry_exports {
3359        Some("--include-entry-exports")
3360    } else if cli.show_cascade {
3361        Some("--show-cascade")
3362    } else {
3363        None
3364    }
3365}
3366
3367struct DispatchContext<'a> {
3368    cli: &'a Cli,
3369    root: &'a std::path::Path,
3370    output: fallow_config::OutputFormat,
3371    quiet: bool,
3372    fail_on_issues: bool,
3373    json_style: json_style::JsonStyle,
3374    threads: usize,
3375    tolerance: regression::Tolerance,
3376    save_regression_file: Option<&'a std::path::PathBuf>,
3377    save_to_config: bool,
3378}
3379
3380impl DispatchContext<'_> {
3381    fn production_modes(
3382        &self,
3383        dead_code: bool,
3384        health: bool,
3385        dupes: bool,
3386    ) -> Result<ProductionModes, ExitCode> {
3387        resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3388    }
3389
3390    fn production_for(
3391        &self,
3392        analysis: fallow_config::ProductionAnalysis,
3393    ) -> Result<bool, ExitCode> {
3394        self.production_modes(false, false, false)
3395            .map(|modes| modes.for_analysis(analysis))
3396    }
3397
3398    fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
3399        baseline_growth::GrowthFlags {
3400            enabled: self.cli.fail_on_baseline_growth,
3401            base: self.cli.baseline_base.as_deref(),
3402            changed_since: self.cli.changed_since.as_deref(),
3403        }
3404    }
3405
3406    fn growth_ctx(
3407        &self,
3408        owner: baseline_growth::GrowthOwner,
3409    ) -> baseline_growth::GrowthContext<'_> {
3410        baseline_growth::GrowthContext {
3411            root: self.root,
3412            flags: self.growth_flags(),
3413            owner,
3414            output: self.output,
3415            json_style: self.json_style,
3416        }
3417    }
3418
3419    fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3420        regression::RegressionOpts {
3421            fail_on_regression: self.cli.fail_on_regression,
3422            tolerance: self.tolerance,
3423            regression_baseline_file: self.cli.regression_baseline.as_deref(),
3424            save_target: if let Some(path) = self.save_regression_file {
3425                regression::SaveRegressionTarget::File(path)
3426            } else if self.save_to_config {
3427                regression::SaveRegressionTarget::Config
3428            } else {
3429                regression::SaveRegressionTarget::None
3430            },
3431            scoped,
3432            quiet: self.quiet,
3433            output: self.output,
3434        }
3435    }
3436}
3437
3438/// Test-only helper invoked when `FALLOW_TEST_SIGNAL_HELPER=1` is set.
3439/// Spawns `sleep 30` via the `ScopedChild` registry so the child is
3440/// tracked by the signal handler, prints the child PID to stdout, then
3441/// busy-waits so a SIGINT/SIGTERM delivered to the parent fires the
3442/// signal handler (which kills the child and exits 128+signum).
3443///
3444/// When `FALLOW_TEST_SIGNAL_HELPER_GRACEFUL=1` is also set, graceful
3445/// mode is activated BEFORE spawning the child. In graceful mode the
3446/// signal handler kills the child (proving drain runs unconditionally)
3447/// but does NOT call `std::process::exit`, so the helper itself sees
3448/// `wait_with_output` return and exits 0. This is the path the
3449/// integration test asserts: graceful drain + clean exit. Lives in
3450/// `main.rs` (not tests/) because clap is already parsed below and we
3451/// need to intercept before that.
3452#[cfg(unix)]
3453fn signal_test_helper() -> ExitCode {
3454    use std::io::Write as _;
3455    use std::process::Command;
3456
3457    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3458        signal::set_graceful_mode();
3459    }
3460
3461    let mut command = Command::new("sleep");
3462    command.arg("30");
3463    let child = match signal::ScopedChild::spawn(&mut command) {
3464        Ok(c) => c,
3465        Err(err) => {
3466            let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3467            return ExitCode::from(2);
3468        }
3469    };
3470    let pid = child.id();
3471    let stdout = std::io::stdout();
3472    let mut lock = stdout.lock();
3473    let _ = writeln!(lock, "{pid}");
3474    let _ = lock.flush();
3475    drop(lock);
3476    let _ = child.wait_with_output();
3477    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3478        return ExitCode::SUCCESS;
3479    }
3480    std::thread::sleep(std::time::Duration::from_secs(5));
3481    ExitCode::SUCCESS
3482}
3483
3484#[cfg(not(unix))]
3485fn signal_test_helper() -> ExitCode {
3486    ExitCode::from(2)
3487}
3488
3489fn install_spawn_hooks() {
3490    fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3491    fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3492}
3493
3494fn install_signal_handlers() {
3495    if let Err(err) = signal::install_handlers() {
3496        use std::io::Write as _;
3497        let stderr = std::io::stderr();
3498        let mut lock = stderr.lock();
3499        let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3500    }
3501}
3502
3503/// Open `path` (creating parent dirs, truncating) and redirect report output
3504/// there via the ambient sink, forcing color off so the file carries no ANSI
3505/// codes even when attached to a TTY. Returns the error exit code if the file
3506/// cannot be created. Backs `--output-file`.
3507fn redirect_report_to_file(
3508    path: &std::path::Path,
3509    output: fallow_config::OutputFormat,
3510) -> Result<(), ExitCode> {
3511    match fallow_engine::write_guard::create_file(
3512        path,
3513        fallow_engine::write_guard::WriteTarget::Path,
3514    ) {
3515        Ok(file) => {
3516            report::sink::set_file_sink(file);
3517            colored::control::set_override(false);
3518            Ok(())
3519        }
3520        Err(e) if e.is_directory() => Err(emit_error(
3521            &format!(
3522                "failed to create the directory of {} for --output-file: {e}",
3523                path.display()
3524            ),
3525            2,
3526            output,
3527        )),
3528        Err(e) => Err(emit_error(
3529            &format!("failed to open {} for --output-file: {e}", path.display()),
3530            2,
3531            output,
3532        )),
3533    }
3534}
3535
3536/// Flush the report file after rendering and print the stderr confirmation
3537/// (suppressed by `--quiet`). Returns the error exit code on a write failure.
3538fn finalize_report_file(
3539    path: &std::path::Path,
3540    quiet: bool,
3541    output: fallow_config::OutputFormat,
3542) -> Result<(), ExitCode> {
3543    if let Err(e) = report::sink::flush() {
3544        return Err(emit_error(
3545            &format!("failed to write {}: {e}", path.display()),
3546            2,
3547            output,
3548        ));
3549    }
3550    // Suppress the confirmation when nothing was rendered to the file (a command
3551    // that errored before producing output sends its error to stdout, not the
3552    // file), so we never claim "Report written" over an empty file.
3553    if !quiet && report::sink::wrote() {
3554        eprintln!("Report written to {}", path.display());
3555    }
3556    Ok(())
3557}
3558
3559/// Run the full fallow CLI: parse argv, dispatch the selected command, and
3560/// return the process exit code. This is the crate's single entry point; the
3561/// `fallow` binary and the multicall `fallow-multicall` binary both delegate
3562/// here so there is exactly one clap tree and one dispatch path.
3563///
3564/// A stdout write error other than a closed pipe changes the exit code to 2,
3565/// so that a cut-off report never looks like a successful run.
3566pub fn run() -> ExitCode {
3567    report::sink::finish_stdout(run_command())
3568}
3569
3570fn run_command() -> ExitCode {
3571    process_clock::mark_process_start();
3572    install_signal_handlers();
3573    install_spawn_hooks();
3574
3575    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3576        return signal_test_helper();
3577    }
3578
3579    let (mut cli, fmt) = match parse_cli_args() {
3580        Ok(parsed) => parsed,
3581        Err(code) => return code,
3582    };
3583    if cli.pretty && !fmt.payload_is_json {
3584        eprintln!(
3585            "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3586        );
3587        return ExitCode::from(2);
3588    }
3589
3590    if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3591        return code;
3592    }
3593
3594    if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3595        return code;
3596    }
3597
3598    if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3599        return code;
3600    }
3601    if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3602        return code;
3603    }
3604    if is_impact_statusline(&cli) {
3605        let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3606            Ok(validated) => validated,
3607            Err(code) => return code,
3608        };
3609        return cli_impact::render_impact_statusline(&root);
3610    }
3611    let telemetry_run = start_telemetry_run(&cli, &fmt);
3612
3613    let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3614        Ok(v) => v,
3615        Err(code) => {
3616            return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref(), None);
3617        }
3618    };
3619
3620    let FormatConfig {
3621        output,
3622        payload_is_json: _,
3623        quiet,
3624        fail_on_issues,
3625        json_style,
3626    } = fmt;
3627
3628    let tolerance =
3629        match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3630            Ok(tolerance) => tolerance,
3631            Err(code) => return code,
3632        };
3633
3634    if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3635        eprintln!("{note}");
3636        cli.no_cache = true;
3637    }
3638
3639    let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3640
3641    // The agent and hooks commands manage the Claude Code setup themselves.
3642    // A plugin hint after `agent uninstall` would ask the user to install
3643    // again at once, so these commands never write the hint.
3644    let hint_root = (!matches!(
3645        cli.command,
3646        Some(Command::Agent { .. } | Command::Hooks { .. } | Command::SetupHooks { .. })
3647    ))
3648    .then_some(root.as_path());
3649    let command = cli.command.take();
3650    process_clock::record_startup();
3651    let dispatch = DispatchContext {
3652        cli: &cli,
3653        root: &root,
3654        output,
3655        quiet,
3656        fail_on_issues,
3657        json_style,
3658        threads,
3659        tolerance,
3660        save_regression_file: save_regression_file.as_ref(),
3661        save_to_config,
3662    };
3663    let exit_code = match dispatch_and_finalize(&dispatch, command) {
3664        Ok(code) => code,
3665        Err(code) => return code,
3666    };
3667    record_run_epilogue(
3668        telemetry_run,
3669        exit_code,
3670        None,
3671        cli.parent_run.as_deref(),
3672        hint_root,
3673    )
3674}
3675
3676/// Benchmark hook for the production fix dry-run pipeline. This is not a
3677/// supported API. Rendered output is disabled so the benchmark measures
3678/// analysis and fix planning rather than terminal I/O.
3679#[doc(hidden)]
3680pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3681    let config_path = None;
3682    fix::run_fix_with_count(&fix::FixOptions {
3683        root,
3684        config_path: &config_path,
3685        output: fallow_config::OutputFormat::Json,
3686        json_style: json_style::JsonStyle::Compact,
3687        no_cache: true,
3688        threads,
3689        quiet: true,
3690        emit_output: false,
3691        allow_remote_extends: false,
3692        dry_run: true,
3693        yes: false,
3694        production: false,
3695        no_create_config: true,
3696        type_aware: None,
3697        type_aware_projects: &[],
3698        type_aware_require: None,
3699        scope: None,
3700        show_cascade: false,
3701    })
3702}
3703
3704/// Owned production-analysis corpus for the stable audit review benchmark.
3705/// This is not a supported API.
3706#[doc(hidden)]
3707pub use audit::AuditReviewBenchmarkCorpus;
3708
3709/// Build audit analysis and preload external review inputs outside the timed
3710/// benchmark. This is not a supported API.
3711#[doc(hidden)]
3712pub fn create_audit_review_benchmark_corpus(
3713    root: &Path,
3714    changed_files: &[PathBuf],
3715    threads: usize,
3716) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3717    audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3718}
3719
3720/// Benchmark production audit review assembly and compact tagged JSON without
3721/// git, cache, worktree, or file I/O. This is not a supported API.
3722#[doc(hidden)]
3723pub fn benchmark_audit_review_brief_many_changed_files_json(
3724    corpus: &mut AuditReviewBenchmarkCorpus,
3725) -> (ExitCode, usize, usize, usize, usize, usize) {
3726    match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3727        Ok(result) => (
3728            ExitCode::SUCCESS,
3729            result.introduced_count,
3730            result.inherited_count,
3731            result.public_api_added_count,
3732            result.decision_count,
3733            result.rendered_bytes,
3734        ),
3735        Err(code) => (code, 0, 0, 0, 0, 0),
3736    }
3737}
3738
3739#[doc(hidden)]
3740pub use inspect::InspectBenchmarkCorpus;
3741
3742/// Build the child-response corpus outside the timed inspect benchmark. This
3743/// is not a supported API.
3744#[doc(hidden)]
3745pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3746    inspect::create_inspect_benchmark_corpus(root, threads)
3747}
3748
3749/// Benchmark file inspect orchestration and compact tagged JSON rendering
3750/// without process startup. This is not a supported API.
3751#[doc(hidden)]
3752pub fn benchmark_inspect_file_evidence_bundle_json(
3753    root: &Path,
3754    threads: usize,
3755    corpus: &InspectBenchmarkCorpus,
3756) -> (ExitCode, usize, usize) {
3757    match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3758        Ok((child_call_count, rendered_bytes)) => {
3759            (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3760        }
3761        Err(_) => (ExitCode::from(2), 0, 0),
3762    }
3763}
3764
3765/// Benchmark hook for the production dead-code analysis and compact JSON
3766/// rendering pipeline. This is not a supported API.
3767#[doc(hidden)]
3768pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3769    match check::benchmark_dead_code_json(root, threads) {
3770        Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3771        Err(code) => (code, 0, 0),
3772    }
3773}
3774
3775/// Benchmark hook for the production security analysis and JSON rendering
3776/// pipeline. This is not a supported API.
3777#[doc(hidden)]
3778pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3779    match security::benchmark_security_json(root, threads) {
3780        Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3781        Err(code) => (code, 0, 0),
3782    }
3783}
3784
3785#[doc(hidden)]
3786pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3787
3788/// Build the explicit candidate and verifier inputs outside the timed
3789/// survivors benchmark. This is not a supported API.
3790#[doc(hidden)]
3791pub fn create_security_survivors_benchmark_corpus(
3792    root: &Path,
3793    threads: usize,
3794) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3795    security::create_security_survivors_benchmark_corpus(root, threads)
3796}
3797
3798/// Benchmark the production survivors loaders, candidate/verdict join, and
3799/// compact JSON serializer. This is not a supported API.
3800#[doc(hidden)]
3801pub fn benchmark_security_survivors_json(
3802    corpus: &SecuritySurvivorsBenchmarkCorpus,
3803) -> (ExitCode, usize, usize, usize, usize, usize) {
3804    match security::benchmark_security_survivors_json(corpus) {
3805        Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3806            ExitCode::SUCCESS,
3807            survivors,
3808            dismissed,
3809            needs_human_review,
3810            unverdicted,
3811            rendered_bytes,
3812        ),
3813        Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3814    }
3815}
3816
3817/// Benchmark unresolved-callee normalization, blind-spot grouping, and compact
3818/// JSON serialization without project I/O. This is not a supported API.
3819#[doc(hidden)]
3820pub fn benchmark_security_blind_spots_json(
3821    root: &Path,
3822    diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3823) -> SecurityBlindSpotsBenchmarkResult {
3824    security::benchmark_security_blind_spots_json(root, diagnostics)
3825}
3826
3827/// Benchmark hook for the production list inventory and JSON rendering
3828/// pipeline. This is not a supported API.
3829#[doc(hidden)]
3830pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3831    match list::benchmark_list_json(root, threads) {
3832        Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3833            ExitCode::SUCCESS,
3834            file_count,
3835            entry_point_count,
3836            workspace_count,
3837            rendered_bytes,
3838        ),
3839        Err(code) => (code, 0, 0, 0, 0),
3840    }
3841}
3842
3843/// Benchmark hook for the production boundaries listing and compact JSON
3844/// rendering pipeline. This is not a supported API.
3845#[doc(hidden)]
3846pub fn benchmark_list_boundaries_json(
3847    root: &Path,
3848    threads: usize,
3849) -> (ExitCode, usize, usize, usize, usize) {
3850    match list::benchmark_list_boundaries_json(root, threads) {
3851        Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3852            ExitCode::SUCCESS,
3853            zone_count,
3854            rule_count,
3855            matched_file_count,
3856            rendered_bytes,
3857        ),
3858        Err(code) => (code, 0, 0, 0, 0),
3859    }
3860}
3861
3862/// Opaque deterministic global matcher for the watch-filter benchmark. This is
3863/// not a supported API.
3864#[doc(hidden)]
3865pub use watch::WatchFilterBenchmarkGlobalGitignore;
3866
3867/// Build a deterministic global gitignore matcher outside the timed watch
3868/// benchmark. This is not a supported API.
3869#[doc(hidden)]
3870pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3871    watch::create_benchmark_global_gitignore()
3872}
3873
3874/// Benchmark hook for production watch-filter initialization and project
3875/// gitignore discovery. This is not a supported API.
3876#[doc(hidden)]
3877pub fn benchmark_watch_filter_initialization(
3878    config: &fallow_config::ResolvedConfig,
3879    global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3880) -> (usize, usize) {
3881    watch::benchmark_filter_initialization(config, global_gitignore)
3882}
3883
3884/// Benchmark hook for the production Viz analysis, payload, and HTML
3885/// rendering pipeline. This is not a supported API.
3886#[doc(hidden)]
3887pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3888    match viz::benchmark_viz_html(root, threads) {
3889        Ok((file_count, edge_count, rendered_bytes)) => {
3890            (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3891        }
3892        Err(code) => (code, 0, 0, 0),
3893    }
3894}
3895
3896/// Benchmark hook for the production rule-pack analysis and JSON rendering
3897/// pipeline. This is not a supported API.
3898#[doc(hidden)]
3899pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3900    match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3901        Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3902        Err(code) => (code, 0, 0),
3903    }
3904}
3905
3906/// Benchmark hook for the production recommendation discovery and compact JSON
3907/// rendering pipeline. This is not a supported API.
3908#[doc(hidden)]
3909pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3910    match onboarding::benchmark_recommend_json(root) {
3911        Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3912            ExitCode::SUCCESS,
3913            decision_count,
3914            framework_count,
3915            heterogeneous,
3916            rendered_bytes,
3917        ),
3918        Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3919    }
3920}
3921
3922/// Benchmark hook for local runtime coverage analysis and compact JSON
3923/// rendering with an in-process sidecar response. This is not a supported API.
3924#[doc(hidden)]
3925pub fn benchmark_runtime_coverage_analyze_json(
3926    root: &Path,
3927    runtime_coverage_path: &Path,
3928    response_bytes: &[u8],
3929    threads: usize,
3930) -> (ExitCode, usize, usize, usize, String) {
3931    match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3932        Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3933            ExitCode::SUCCESS,
3934            finding_count,
3935            hot_path_count,
3936            request_bytes,
3937            rendered,
3938        ),
3939        Err(code) => (code, 0, 0, 0, String::new()),
3940    }
3941}
3942
3943/// Status bars refresh frequently, so their local read path bypasses telemetry,
3944/// update checks, notices, and every other command epilogue.
3945fn is_impact_statusline(cli: &Cli) -> bool {
3946    matches!(
3947        cli.command.as_ref(),
3948        Some(Command::Impact {
3949            subcommand: Some(ImpactCli::Statusline),
3950            all: false,
3951            ..
3952        })
3953    )
3954}
3955
3956/// Redirect the rendered report to `--output-file` (ambient sink), dispatch the
3957/// command, then flush+close the report file. Returns the dispatch exit code, or
3958/// `Err` carrying a redirect/finalize failure code for `main` to return directly.
3959fn dispatch_and_finalize(
3960    dispatch: &DispatchContext<'_>,
3961    command: Option<Command>,
3962) -> Result<ExitCode, ExitCode> {
3963    let cli = dispatch.cli;
3964    let output = dispatch.output;
3965    let quiet = dispatch.quiet;
3966
3967    // Set up the report-file sink before dispatch so rendering lands in the file;
3968    // progress and the confirmation stay on stderr.
3969    if let Some(path) = cli.output_file.as_deref()
3970        && let Err(code) = redirect_report_to_file(path, output)
3971    {
3972        return Err(code);
3973    }
3974
3975    let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3976        emit_error(bare_coverage_subcommand_error_message(), 2, output)
3977    } else if command.is_some()
3978        && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3979    {
3980        emit_error(
3981            &bare_combined_baseline_subcommand_error_message(flag),
3982            2,
3983            output,
3984        )
3985    } else {
3986        match command {
3987            None => dispatch_bare_command(dispatch),
3988            Some(cmd) => dispatch_subcommand(cmd, dispatch),
3989        }
3990    };
3991
3992    if let Some(path) = cli.output_file.as_deref()
3993        && let Err(code) = finalize_report_file(path, quiet, output)
3994    {
3995        return Err(code);
3996    }
3997    Ok(exit_code)
3998}
3999
4000fn run_telemetry_command_if_requested(
4001    cli: &mut Cli,
4002    output: fallow_config::OutputFormat,
4003    json_style: json_style::JsonStyle,
4004) -> Option<ExitCode> {
4005    if matches!(cli.command, Some(Command::Telemetry { .. }))
4006        && let Some(Command::Telemetry { subcommand }) = cli.command.take()
4007    {
4008        return Some(telemetry::run(
4009            map_telemetry_subcommand(subcommand),
4010            output,
4011            json_style,
4012        ));
4013    }
4014    None
4015}
4016
4017/// Doctor deliberately bypasses the normal run epilogue: collecting readiness
4018/// must not write telemetry, flush a spool, perform an update check, or create
4019/// caches. Root validation stays inside the typed report so failures still
4020/// return the complete doctor envelope.
4021fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
4022    if !matches!(cli.command, Some(Command::Doctor)) {
4023        return None;
4024    }
4025
4026    if let Some(flag) = unsupported_doctor_option(cli) {
4027        let message = if flag == "--output-file" {
4028            "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
4029        } else {
4030            format!("{flag} is not valid with `fallow doctor`.")
4031        };
4032        return Some(crate::error::emit_error_with_style(
4033            &message,
4034            2,
4035            format.output,
4036            format.json_style,
4037        ));
4038    }
4039    if let Err(code) = doctor::validate_output(format.output, format.json_style) {
4040        return Some(code);
4041    }
4042
4043    let root = cli.root.clone().unwrap_or_else(|| {
4044        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
4045    });
4046    let config_path = cli.config.as_ref().map(|path| {
4047        if path_util::is_absolute_path_any_platform(path) {
4048            path.clone()
4049        } else {
4050            root.join(path)
4051        }
4052    });
4053    let report = doctor::collect_report(&root, config_path.as_deref());
4054    Some(doctor::render_report(
4055        &report,
4056        format.output,
4057        format.json_style,
4058    ))
4059}
4060
4061/// Doctor accepts only project selection and presentation options. Rejecting
4062/// analysis flags avoids implying that a readiness check ran an analysis or
4063/// applied a gate that it deliberately bypasses.
4064fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
4065    let dupes_flag = cli.dupes_overrides().first_global_flag();
4066    [
4067        (cli.allow_remote_extends, "--allow-remote-extends"),
4068        (cli.no_cache, "--no-cache"),
4069        (cli.threads.is_some(), "--threads"),
4070        (cli.changed_since.is_some(), "--changed-since"),
4071        (cli.diff_file.is_some(), "--diff-file"),
4072        (cli.diff_stdin, "--diff-stdin"),
4073        (cli.churn_file.is_some(), "--churn-file"),
4074        (cli.max_file_size.is_some(), "--max-file-size"),
4075        (cli.baseline.is_some(), "--baseline"),
4076        (cli.baseline_mode.is_some(), "--baseline-mode"),
4077        (cli.parent_run.is_some(), "--parent-run"),
4078        (cli.save_baseline.is_some(), "--save-baseline"),
4079        (cli.production, "--production"),
4080        (cli.no_production, "--no-production"),
4081        (cli.no_package_baselines, "--no-package-baselines"),
4082        (cli.production_dead_code, "--production-dead-code"),
4083        (cli.production_health, "--production-health"),
4084        (cli.production_dupes, "--production-dupes"),
4085        (cli.workspace.is_some(), "--workspace"),
4086        (cli.changed_workspaces.is_some(), "--changed-workspaces"),
4087        (cli.group_by.is_some(), "--group-by"),
4088        (cli.performance, "--performance"),
4089        (cli.explain, "--explain"),
4090        (cli.explain_skipped, "--explain-skipped"),
4091        (cli.summary, "--summary"),
4092        (cli.ci, "--ci"),
4093        (cli.fail_on_issues, "--fail-on-issues"),
4094        (cli.sarif_file.is_some(), "--sarif-file"),
4095        (cli.output_file.is_some(), "--output-file"),
4096        (cli.report_path_prefix.is_some(), "--report-path-prefix"),
4097        (cli.fail_on_regression, "--fail-on-regression"),
4098        (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
4099        (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
4100        (cli.baseline_base.is_some(), "--baseline-base"),
4101        (cli.fail_on_parse_error, "--fail-on-parse-error"),
4102        (cli.tolerance != "0", "--tolerance"),
4103        (cli.regression_baseline.is_some(), "--regression-baseline"),
4104        (
4105            cli.save_regression_baseline.is_some(),
4106            "--save-regression-baseline",
4107        ),
4108        (!cli.only.is_empty(), "--only"),
4109        (!cli.skip.is_empty(), "--skip"),
4110        (dupes_flag.is_some(), dupes_flag.unwrap_or_default()),
4111        (cli.score, "--score"),
4112        (cli.trend, "--trend"),
4113        (cli.trend_from.is_some(), "--trend-from"),
4114        (cli.save_snapshot.is_some(), "--save-snapshot"),
4115        (cli.coverage.is_some(), "--coverage"),
4116        (cli.coverage_root.is_some(), "--coverage-root"),
4117        (cli.dupes_baseline.is_some(), "--dupes-baseline"),
4118        (cli.health_baseline.is_some(), "--health-baseline"),
4119        (cli.include_entry_exports, "--include-entry-exports"),
4120        (cli.show_cascade, "--show-cascade"),
4121        (cli.type_aware, "--type-aware"),
4122        (cli.no_type_aware, "--no-type-aware"),
4123        (!cli.type_aware_project.is_empty(), "--type-aware-project"),
4124        (cli.type_aware_require.is_some(), "--type-aware-require"),
4125    ]
4126    .into_iter()
4127    .find_map(|(used, flag)| used.then_some(flag))
4128}
4129
4130fn run_schema_command_if_requested(
4131    cli: &Cli,
4132    json_style: json_style::JsonStyle,
4133) -> Option<ExitCode> {
4134    match cli.command {
4135        Some(Command::Schema) => Some(schema::run_schema(json_style)),
4136        Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
4137        Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
4138        Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
4139        _ => None,
4140    }
4141}
4142
4143fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
4144    let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
4145        opt.as_ref()
4146            .filter(|path| !path.is_empty())
4147            .map(std::path::PathBuf::from)
4148    });
4149    let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
4150    (save_file, save_to_config)
4151}
4152
4153fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
4154    let cli = dispatch.cli;
4155    let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
4156    let production = match dispatch.production_modes(
4157        cli.production_dead_code,
4158        cli.production_health,
4159        cli.production_dupes,
4160    ) {
4161        Ok(production) => production,
4162        Err(code) => return code,
4163    };
4164    // Coverage only feeds health scoring, and resolving it validates the
4165    // winning root. A bare run that excludes health (`--only check`,
4166    // `--skip health`) must neither load config for coverage nor reject a
4167    // relative `health.coverageRoot` it never reads.
4168    let coverage_inputs = if run_health {
4169        match resolve_health_coverage_inputs(
4170            dispatch,
4171            cli.coverage.as_deref(),
4172            cli.coverage_root.as_deref(),
4173        ) {
4174            Ok(inputs) => inputs,
4175            Err(code) => return code,
4176        }
4177    } else {
4178        ResolvedHealthCoverageInputs::default()
4179    };
4180    run_bare_combined(
4181        dispatch,
4182        production,
4183        &coverage_inputs,
4184        BareAnalyses {
4185            run_check,
4186            run_dupes,
4187            run_health,
4188        },
4189    )
4190}
4191
4192/// Which analyses the bare `fallow` run executes (resolved from `--only`/`--skip`).
4193#[derive(Clone, Copy)]
4194struct BareAnalyses {
4195    run_check: bool,
4196    run_dupes: bool,
4197    run_health: bool,
4198}
4199
4200/// Build `CombinedOptions` for a bare `fallow` invocation and run the combined
4201/// pipeline.
4202fn run_bare_combined(
4203    dispatch: &DispatchContext<'_>,
4204    production: ProductionModes,
4205    coverage_inputs: &ResolvedHealthCoverageInputs,
4206    analyses: BareAnalyses,
4207) -> ExitCode {
4208    let cli = dispatch.cli;
4209    let output = dispatch.output;
4210    if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
4211        return error::emit_error_with_style(
4212            "--fail-on-parse-error needs the dead-code or health analysis, and this run analyzes neither. Include dead-code or health in --only or --skip, or remove the flag.",
4213            2,
4214            output,
4215            dispatch.json_style,
4216        );
4217    }
4218    let scope = match crate::scope_path::resolve_command_scope(
4219        dispatch.root,
4220        dispatch.output,
4221        cli.path.clone(),
4222    ) {
4223        Ok(scope) => scope.map(|resolved| resolved.absolute),
4224        Err(code) => return code,
4225    };
4226    let growth_targets = baseline_growth::targets(&[
4227        (
4228            cli.baseline.as_deref().filter(|_| analyses.run_check),
4229            fallow_engine::baseline::BaselineKind::DeadCode,
4230        ),
4231        (
4232            cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
4233            fallow_engine::baseline::BaselineKind::Dupes,
4234        ),
4235        (
4236            cli.health_baseline
4237                .as_deref()
4238                .filter(|_| analyses.run_health),
4239            fallow_engine::baseline::BaselineKind::Health,
4240        ),
4241    ]);
4242    baseline_growth::run_gated(
4243        &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
4244        &growth_targets,
4245        || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
4246    )
4247}
4248
4249fn run_combined_scoped(
4250    dispatch: &DispatchContext<'_>,
4251    production: ProductionModes,
4252    coverage_inputs: &ResolvedHealthCoverageInputs,
4253    analyses: BareAnalyses,
4254    scope: Option<PathBuf>,
4255) -> ExitCode {
4256    let cli = dispatch.cli;
4257    let (output, quiet, fail_on_issues) =
4258        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4259    let scoped_run = scope.is_some();
4260    let architecture = combined::resolve_architecture_selection(&cli.only, &cli.skip);
4261    // A narrowed dead-code section saves narrowed regression counts.
4262    let narrowed_report = architecture != check::ArchitectureSelection::All;
4263    combined::run_combined(&combined::CombinedOptions {
4264        root: dispatch.root,
4265        config_path: &cli.config,
4266        output,
4267        json_style: dispatch.json_style,
4268        no_cache: cli.no_cache,
4269        threads: dispatch.threads,
4270        quiet,
4271        allow_remote_extends: cli.allow_remote_extends,
4272        fail_on_issues,
4273        sarif_file: cli.sarif_file.as_deref(),
4274        changed_since: cli.changed_since.as_deref(),
4275        no_package_baselines: cli.no_package_baselines,
4276        churn_file: cli.churn_file.as_deref(),
4277        baseline: cli.baseline.as_deref(),
4278        save_baseline: cli.save_baseline.as_deref(),
4279        dupes_baseline: cli.dupes_baseline.as_deref(),
4280        health_baseline: cli.health_baseline.as_deref(),
4281        health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
4282        health_baseline_mode_explicit: cli.baseline_mode.is_some(),
4283        fail_on_stale_baseline: cli.fail_on_stale_baseline,
4284        production: cli.production,
4285        production_dead_code: Some(production.dead_code),
4286        production_health: Some(production.health),
4287        production_dupes: Some(production.dupes),
4288        workspace: cli.workspace.as_deref(),
4289        changed_workspaces: cli.changed_workspaces.as_deref(),
4290        group_by: cli.group_by,
4291        type_aware: cli.type_aware_override(),
4292        type_aware_projects: &cli.type_aware_project,
4293        type_aware_require: cli.type_aware_require.map(Into::into),
4294        explain: cli.explain,
4295        explain_skipped: cli.explain_skipped,
4296        performance: cli.performance,
4297        summary: cli.summary,
4298        run_check: analyses.run_check,
4299        run_dupes: analyses.run_dupes,
4300        run_health: analyses.run_health,
4301        architecture,
4302        dupes: cli.dupes_overrides(),
4303        score: cli.score || cli.trend || cli.trend_from.is_some(),
4304        trend: cli.trend || cli.trend_from.is_some(),
4305        trend_from: cli.trend_from.as_deref(),
4306        save_snapshot: cli.save_snapshot.as_ref(),
4307        coverage: coverage_inputs.coverage.as_deref(),
4308        coverage_root: coverage_inputs.coverage_root.as_deref(),
4309        include_entry_exports: cli.include_entry_exports,
4310        show_cascade: cli.show_cascade,
4311        fail_on_parse_error: cli.fail_on_parse_error,
4312        scope,
4313        regression_opts: dispatch.regression_opts(
4314            cli.changed_since.is_some()
4315                || cli.workspace.is_some()
4316                || cli.changed_workspaces.is_some()
4317                || scoped_run
4318                || narrowed_report,
4319        ),
4320    })
4321}
4322
4323#[allow(
4324    clippy::too_many_lines,
4325    reason = "the command router is intentionally an exhaustive top-level dispatch table"
4326)]
4327fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4328    let cli = dispatch.cli;
4329    let root = dispatch.root;
4330    let output = dispatch.output;
4331    let quiet = dispatch.quiet;
4332    match command {
4333        check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
4334        architecture @ Command::Architecture { .. } => {
4335            dispatch_architecture_command(architecture, dispatch)
4336        }
4337        Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
4338        Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
4339        Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
4340        Command::SimilarCode {
4341            subcommand,
4342            threshold,
4343            min_lines,
4344            top,
4345            file,
4346            path,
4347        } => {
4348            let scope = match crate::scope_path::resolve_command_scope(
4349                dispatch.root,
4350                dispatch.output,
4351                path,
4352            ) {
4353                Ok(scope) => scope,
4354                Err(code) => return code,
4355            };
4356            similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
4357                root,
4358                config_path: cli.config.as_deref(),
4359                allow_remote_extends: cli.allow_remote_extends,
4360                no_cache: cli.no_cache,
4361                threads: dispatch.threads,
4362                changed_since: cli.changed_since.as_deref(),
4363                diff_file: cli.diff_file.as_deref(),
4364                workspace: cli.workspace.as_deref(),
4365                changed_workspaces: cli.changed_workspaces.as_deref(),
4366                explain: cli.explain,
4367                quiet,
4368                output,
4369                json_style: dispatch.json_style,
4370                threshold,
4371                min_lines,
4372                top,
4373                files: file,
4374                scope,
4375                subcommand,
4376            })
4377        }
4378        Command::Inspect {
4379            file,
4380            symbol,
4381            symbol_chain,
4382            churn,
4383        } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
4384        Command::Trace {
4385            symbol,
4386            dependency,
4387            specifier,
4388            sites,
4389            limit,
4390            cursor,
4391            path,
4392            eager_only,
4393            callers,
4394            callees,
4395            depth,
4396        } => {
4397            if let Some(package_name) = dependency {
4398                return dispatch_trace_dependency(
4399                    dispatch,
4400                    trace_dependency::TraceDependencyTarget {
4401                        package_name,
4402                        specifiers: specifier,
4403                        sites,
4404                        limit,
4405                        cursor,
4406                        callers,
4407                        depth,
4408                    },
4409                );
4410            }
4411            dispatch_trace_command(
4412                dispatch,
4413                symbol,
4414                &path,
4415                eager_only,
4416                TraceChainFlags {
4417                    callers,
4418                    callees,
4419                    depth,
4420                },
4421            )
4422        }
4423        Command::TraceError { trace_file } => {
4424            trace_error::run_trace_error(&trace_error::TraceErrorOptions {
4425                root: dispatch.root,
4426                config_path: &dispatch.cli.config,
4427                output: dispatch.output,
4428                json_style: dispatch.json_style,
4429                no_cache: dispatch.cli.no_cache,
4430                threads: dispatch.threads,
4431                quiet: dispatch.quiet,
4432                allow_remote_extends: dispatch.cli.allow_remote_extends,
4433                trace_file: trace_file.as_deref(),
4434            })
4435        }
4436        fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
4437        init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
4438        Command::Hooks { subcommand } => {
4439            run_hooks_command(root, subcommand, output, dispatch.json_style)
4440        }
4441        Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
4442        Command::Ci { subcommand } => {
4443            ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
4444        }
4445        Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
4446        Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
4447        Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
4448        Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
4449        Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
4450        Command::Guard { files } => dispatch_guard_command(dispatch, &files),
4451        Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
4452        Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
4453            root,
4454            explicit_config: cli.config.as_deref(),
4455            path_only: path,
4456            output,
4457            quiet,
4458            json_style: dispatch.json_style,
4459            load_options: fallow_config::ConfigLoadOptions {
4460                allow_remote_extends: cli.allow_remote_extends,
4461            },
4462        }),
4463        Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4464        list @ (Command::Workspaces | Command::List { .. }) => {
4465            dispatch_list_command(&list, dispatch)
4466        }
4467        dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4468        health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4469        Command::Flags {
4470            top,
4471            retirement,
4472            reasons,
4473            sort,
4474            flag_age,
4475            min_age,
4476            flag_state,
4477            max_flag_age,
4478        } => dispatch_flags_command(
4479            dispatch,
4480            top,
4481            retirement.then_some(flags::RetirementArgs {
4482                reasons,
4483                sort,
4484                flag_age,
4485                min_age,
4486                flag_state,
4487                max_flag_age,
4488            }),
4489        ),
4490        Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4491        Command::Explain { issue_type } => {
4492            explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4493        }
4494        audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4495        Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4496        Command::Baselines { subcommand } => dispatch_baselines_command(dispatch, &subcommand),
4497        Command::DecisionSurface { max_decisions } => {
4498            dispatch_decision_surface(dispatch, max_decisions)
4499        }
4500        Command::Impact {
4501            subcommand,
4502            all,
4503            sort,
4504            limit,
4505        } => dispatch_impact(
4506            root,
4507            quiet,
4508            output,
4509            dispatch.json_style,
4510            subcommand,
4511            ImpactCrossRepoOpts { all, sort, limit },
4512        ),
4513        security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4514        Command::Viz {
4515            output: viz_output,
4516            no_open,
4517            viz_format,
4518        } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4519        Command::Report { from } => {
4520            cli_report::run_report(&from, output, root, cli.config.as_deref(), cli.quiet)
4521        }
4522        Command::Schema => unreachable!("handled above"),
4523        migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4524        Command::License { subcommand } => {
4525            dispatch_license_command(subcommand, output, dispatch.json_style)
4526        }
4527        Command::Telemetry { .. } => unreachable!("handled before root validation"),
4528        Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4529        setup_hooks @ Command::SetupHooks { .. } => {
4530            dispatch_setup_hooks_command(&setup_hooks, dispatch)
4531        }
4532    }
4533}
4534
4535fn dispatch_type_aware_command(
4536    dispatch: &DispatchContext<'_>,
4537    subcommand: TypeAwareCli,
4538) -> ExitCode {
4539    match subcommand {
4540        TypeAwareCli::Status => {
4541            let status = fallow_api::type_aware_status(dispatch.root);
4542            match dispatch.output {
4543                fallow_config::OutputFormat::Json => {
4544                    let output = type_aware_status_output(dispatch.root, status);
4545                    match fallow_output::serialize_type_aware_status_json_output(output) {
4546                        Ok(value) => match dispatch.json_style.serialize(&value) {
4547                            Ok(json) => {
4548                                crate::report::sink::outln!("{json}");
4549                                ExitCode::SUCCESS
4550                            }
4551                            Err(error) => emit_error(
4552                                &format!("failed to serialize type-aware status: {error}"),
4553                                2,
4554                                dispatch.output,
4555                            ),
4556                        },
4557                        Err(error) => emit_error(
4558                            &format!("failed to build type-aware status: {error}"),
4559                            2,
4560                            dispatch.output,
4561                        ),
4562                    }
4563                }
4564                fallow_config::OutputFormat::Human => {
4565                    if status.available {
4566                        crate::report::sink::outln!(
4567                            "{}",
4568                            report::human_status_line(
4569                                report::HumanStatus::Ok,
4570                                format_args!(
4571                                    "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4572                                    status.package_version.as_deref().unwrap_or("unknown"),
4573                                    status.protocol_version,
4574                                    status.backend_version.as_deref().unwrap_or("unknown"),
4575                                )
4576                            )
4577                        );
4578                    } else {
4579                        crate::report::sink::outln!(
4580                            "{}",
4581                            report::human_status_line(
4582                                report::HumanStatus::Inactive,
4583                                "Type-aware companion: unavailable"
4584                            )
4585                        );
4586                        if let Some(remediation) = status.remediation {
4587                            crate::report::sink::outln!(
4588                                "{}",
4589                                report::human_status_line(
4590                                    report::HumanStatus::Warning,
4591                                    format_args!("Action: {remediation}")
4592                                )
4593                            );
4594                        }
4595                    }
4596                    ExitCode::SUCCESS
4597                }
4598                _ => emit_error(
4599                    "type-aware status supports human and json output",
4600                    2,
4601                    dispatch.output,
4602                ),
4603            }
4604        }
4605    }
4606}
4607
4608fn type_aware_status_output(
4609    root: &Path,
4610    status: fallow_api::TypeAwareStatus,
4611) -> fallow_output::TypeAwareStatusOutput {
4612    let companion_path = status.companion_path.as_deref().map(|path| {
4613        if let Ok(relative) = path.strip_prefix(root)
4614            && !relative.as_os_str().is_empty()
4615        {
4616            relative.to_string_lossy().replace('\\', "/")
4617        } else {
4618            path.file_name()
4619                .unwrap_or(path.as_os_str())
4620                .to_string_lossy()
4621                .into_owned()
4622        }
4623    });
4624    let remediation = status.remediation.map(|message| {
4625        let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4626        status.companion_path.as_deref().map_or_else(
4627            || without_root.clone(),
4628            |path| {
4629                without_root.replace(
4630                    path.to_string_lossy().as_ref(),
4631                    companion_path.as_deref().unwrap_or("fallow-type-aware"),
4632                )
4633            },
4634        )
4635    });
4636    fallow_output::TypeAwareStatusOutput {
4637        schema_version: fallow_types::envelope::SchemaVersion(
4638            fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4639        ),
4640        version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4641        available: status.available,
4642        discovery_source: status.discovery_source.map(str::to_string),
4643        companion_path,
4644        package_version: status.package_version,
4645        protocol_version: status.protocol_version,
4646        backend_family: status.backend_family,
4647        backend_version: status.backend_version,
4648        remediation,
4649    }
4650}
4651
4652/// Destructure the `Command::Check` arm and forward to `dispatch_check`.
4653fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4654    let filters = check_issue_filters(&command);
4655    let Command::Check {
4656        include_dupes,
4657        trace,
4658        trace_file,
4659        trace_dependency,
4660        impact_closure,
4661        symbol_impact,
4662        top,
4663        file,
4664        finding_id,
4665        path,
4666        ..
4667    } = command
4668    else {
4669        unreachable!("check dispatcher only handles check commands");
4670    };
4671
4672    let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4673        Ok(filter) => filter,
4674        Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4675    };
4676
4677    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4678    {
4679        Ok(scope) => scope.map(|resolved| resolved.absolute),
4680        Err(code) => return code,
4681    };
4682
4683    dispatch_check(
4684        dispatch,
4685        &CheckDispatchArgs {
4686            filters,
4687            trace_opts: TraceOptions {
4688                trace_export: trace,
4689                trace_file,
4690                trace_dependency,
4691                impact_closure,
4692                symbol_impact,
4693                performance: dispatch.cli.performance,
4694            },
4695            include_dupes,
4696            type_aware: dispatch.cli.type_aware_override(),
4697            type_aware_project: dispatch.cli.type_aware_project.clone(),
4698            type_aware_require: dispatch.cli.type_aware_require,
4699            top,
4700            file,
4701            scope,
4702            finding_ids,
4703            surface: check::CheckSurface::DeadCode,
4704        },
4705    )
4706}
4707
4708/// Destructure the `Command::Architecture` arm and forward to `dispatch_check`
4709/// with the architecture issue types selected. The analysis, the JSON envelope
4710/// and the finding ids are the ones `dead-code` produces for the same filters.
4711fn dispatch_architecture_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4712    let Command::Architecture {
4713        cycles,
4714        boundaries,
4715        policy,
4716        file,
4717        finding_id,
4718        path,
4719    } = command
4720    else {
4721        unreachable!("architecture dispatcher only handles architecture commands");
4722    };
4723
4724    let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4725        Ok(filter) => filter,
4726        Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4727    };
4728
4729    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4730    {
4731        Ok(scope) => scope.map(|resolved| resolved.absolute),
4732        Err(code) => return code,
4733    };
4734
4735    dispatch_check(
4736        dispatch,
4737        &CheckDispatchArgs {
4738            filters: architecture_issue_filters(cycles, boundaries, policy),
4739            trace_opts: TraceOptions {
4740                trace_export: None,
4741                trace_file: None,
4742                trace_dependency: None,
4743                impact_closure: None,
4744                symbol_impact: None,
4745                performance: dispatch.cli.performance,
4746            },
4747            include_dupes: false,
4748            type_aware: dispatch.cli.type_aware_override(),
4749            type_aware_project: dispatch.cli.type_aware_project.clone(),
4750            type_aware_require: dispatch.cli.type_aware_require,
4751            top: None,
4752            file,
4753            scope,
4754            finding_ids,
4755            surface: check::CheckSurface::Architecture,
4756        },
4757    )
4758}
4759
4760/// Map the `fallow architecture` filter flags onto the `dead-code` filter flags
4761/// they stand for. No flag selects every architecture issue type.
4762fn architecture_issue_filters(cycles: bool, boundaries: bool, policy: bool) -> IssueFilters {
4763    let all = !(cycles || boundaries || policy);
4764    let mut filters = IssueFilters::default();
4765    for (flag, active) in [
4766        ("--circular-deps", cycles || all),
4767        ("--re-export-cycles", cycles || all),
4768        ("--package-cycles", cycles || all),
4769        ("--boundary-violations", boundaries || all),
4770        ("--policy-violations", policy || all),
4771    ] {
4772        enable_check_filter(&mut filters, flag, active);
4773    }
4774    filters
4775}
4776
4777/// Map the `Command::Check` filter flags onto `IssueFilters`. Reads the flags by
4778/// reference (all `Copy` bools) so the caller can still move the non-filter
4779/// fields out of the same `Command` value afterwards. Split into two halves to
4780/// keep each builder within the unit-size limit.
4781fn check_issue_filters(command: &Command) -> IssueFilters {
4782    check_issue_filters_framework(command, &check_issue_filters_core(command))
4783}
4784
4785/// First half of the `IssueFilters` mapping: core/general filter flags over a
4786/// `Default` base. The framework/catalog half layers on top via struct update.
4787fn check_issue_filters_core(command: &Command) -> IssueFilters {
4788    let Command::Check {
4789        unused_files,
4790        unused_exports,
4791        unused_deps,
4792        unused_types,
4793        private_type_leaks,
4794        deprecated_exports_in_use,
4795        unused_enum_members,
4796        unused_class_members,
4797        unresolved_imports,
4798        unlisted_deps,
4799        duplicate_exports,
4800        circular_deps,
4801        re_export_cycles,
4802        package_cycles,
4803        boundary_violations,
4804        policy_violations,
4805        stale_suppressions,
4806        ..
4807    } = command
4808    else {
4809        unreachable!("check filter builder only handles check commands");
4810    };
4811
4812    let mut filters = IssueFilters::default();
4813    for (flag, active) in [
4814        ("--unused-files", *unused_files),
4815        ("--unused-exports", *unused_exports),
4816        ("--unused-deps", *unused_deps),
4817        ("--unused-types", *unused_types),
4818        ("--private-type-leaks", *private_type_leaks),
4819        ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4820        ("--unused-enum-members", *unused_enum_members),
4821        ("--unused-class-members", *unused_class_members),
4822        ("--unresolved-imports", *unresolved_imports),
4823        ("--unlisted-deps", *unlisted_deps),
4824        ("--duplicate-exports", *duplicate_exports),
4825        ("--circular-deps", *circular_deps),
4826        ("--re-export-cycles", *re_export_cycles),
4827        ("--package-cycles", *package_cycles),
4828        ("--boundary-violations", *boundary_violations),
4829        ("--policy-violations", *policy_violations),
4830        ("--stale-suppressions", *stale_suppressions),
4831    ] {
4832        enable_check_filter(&mut filters, flag, active);
4833    }
4834    filters
4835}
4836
4837/// Second half of the `IssueFilters` mapping: framework/component, store, svelte,
4838/// catalog, and dependency-override flags, layered onto the core `base`.
4839fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4840    let Command::Check {
4841        unused_store_members,
4842        unprovided_injects,
4843        unrendered_components,
4844        unused_component_props,
4845        absent_component_props,
4846        unused_component_emits,
4847        unused_component_inputs,
4848        unused_component_outputs,
4849        unused_svelte_events,
4850        unused_server_actions,
4851        unused_load_data_keys,
4852        unused_catalog_entries,
4853        empty_catalog_groups,
4854        unresolved_catalog_references,
4855        unused_dependency_overrides,
4856        misconfigured_dependency_overrides,
4857        ..
4858    } = command
4859    else {
4860        unreachable!("check filter builder only handles check commands");
4861    };
4862
4863    let mut filters = base.clone();
4864    for (flag, active) in [
4865        ("--unused-store-members", *unused_store_members),
4866        ("--unprovided-injects", *unprovided_injects),
4867        ("--unrendered-components", *unrendered_components),
4868        ("--unused-component-props", *unused_component_props),
4869        ("--absent-component-props", *absent_component_props),
4870        ("--unused-component-emits", *unused_component_emits),
4871        ("--unused-component-inputs", *unused_component_inputs),
4872        ("--unused-component-outputs", *unused_component_outputs),
4873        ("--unused-svelte-events", *unused_svelte_events),
4874        ("--unused-server-actions", *unused_server_actions),
4875        ("--unused-load-data-keys", *unused_load_data_keys),
4876        ("--unused-catalog-entries", *unused_catalog_entries),
4877        ("--empty-catalog-groups", *empty_catalog_groups),
4878        (
4879            "--unresolved-catalog-references",
4880            *unresolved_catalog_references,
4881        ),
4882        (
4883            "--unused-dependency-overrides",
4884            *unused_dependency_overrides,
4885        ),
4886        (
4887            "--misconfigured-dependency-overrides",
4888            *misconfigured_dependency_overrides,
4889        ),
4890    ] {
4891        enable_check_filter(&mut filters, flag, active);
4892    }
4893    filters
4894}
4895
4896fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4897    if active {
4898        assert!(
4899            filters.enable_cli_filter_flag(flag),
4900            "check command uses unregistered dead-code filter flag {flag}"
4901        );
4902    }
4903}
4904
4905fn dispatch_inspect_command(
4906    dispatch: &DispatchContext<'_>,
4907    file: Option<String>,
4908    symbol: Option<String>,
4909    symbol_chain: bool,
4910    churn: bool,
4911) -> ExitCode {
4912    let target = match (file, symbol) {
4913        (Some(file), None) => inspect::InspectTarget::File { file },
4914        (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4915            Some((file, export_name)) => inspect::InspectTarget::Symbol {
4916                file: file.to_string(),
4917                export_name: export_name.to_string(),
4918            },
4919            None => {
4920                return emit_error(
4921                    "--symbol must be formatted as FILE:EXPORT",
4922                    2,
4923                    dispatch.output,
4924                );
4925            }
4926        },
4927        _ => {
4928            return emit_error(
4929                "inspect requires exactly one of --file or --symbol",
4930                2,
4931                dispatch.output,
4932            );
4933        }
4934    };
4935
4936    let churn_config = if churn {
4937        match load_config_for_analysis(
4938            dispatch.root,
4939            &dispatch.cli.config,
4940            ConfigLoadOptions {
4941                output: dispatch.output,
4942                no_cache: dispatch.cli.no_cache,
4943                threads: dispatch.threads,
4944                production_override: None,
4945                quiet: dispatch.quiet,
4946                allow_remote_extends: dispatch.cli.allow_remote_extends,
4947            },
4948            fallow_config::ProductionAnalysis::Health,
4949        ) {
4950            Ok(config) => Some(config),
4951            Err(code) => return code,
4952        }
4953    } else {
4954        None
4955    };
4956
4957    inspect::run_inspect(&inspect::InspectOptions {
4958        root: dispatch.root,
4959        config_path: dispatch.cli.config.as_ref(),
4960        output: dispatch.output,
4961        json_style: dispatch.json_style,
4962        no_cache: dispatch.cli.no_cache,
4963        no_production: dispatch.cli.no_production,
4964        max_file_size: dispatch.cli.max_file_size,
4965        threads: dispatch.threads,
4966        quiet: dispatch.quiet,
4967        production: dispatch.cli.production,
4968        workspace: dispatch.cli.workspace.as_ref(),
4969        target,
4970        churn_cache_dir: churn_config
4971            .as_ref()
4972            .map(|config| config.cache_dir.as_path()),
4973        symbol_chain,
4974        type_aware: dispatch.cli.type_aware_override(),
4975        type_aware_projects: &dispatch.cli.type_aware_project,
4976        type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4977    })
4978}
4979
4980/// The call-chain flags of `fallow trace FILE:SYMBOL`.
4981#[derive(Clone, Copy)]
4982struct TraceChainFlags {
4983    callers: bool,
4984    callees: bool,
4985    depth: Option<u32>,
4986}
4987
4988fn dispatch_trace_dependency(
4989    dispatch: &DispatchContext<'_>,
4990    target: trace_dependency::TraceDependencyTarget,
4991) -> ExitCode {
4992    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4993        Ok(production) => production,
4994        Err(code) => return code,
4995    };
4996    trace_dependency::run_trace_dependency(&trace_dependency::TraceDependencyOptions {
4997        root: dispatch.root,
4998        config_path: &dispatch.cli.config,
4999        output: dispatch.output,
5000        json_style: dispatch.json_style,
5001        no_cache: dispatch.cli.no_cache,
5002        threads: dispatch.threads,
5003        quiet: dispatch.quiet,
5004        allow_remote_extends: dispatch.cli.allow_remote_extends,
5005        production,
5006        workspace: dispatch.cli.workspace.as_deref(),
5007        changed_workspaces: dispatch.cli.changed_workspaces.as_deref(),
5008        target,
5009    })
5010}
5011
5012fn dispatch_trace_command(
5013    dispatch: &DispatchContext<'_>,
5014    symbol: Option<String>,
5015    path: &[String],
5016    eager_only: bool,
5017    chain: TraceChainFlags,
5018) -> ExitCode {
5019    let TraceChainFlags {
5020        callers,
5021        callees,
5022        depth,
5023    } = chain;
5024    if let [from, to] = path {
5025        return trace_path::run_trace_path(&trace_path::TracePathOptions {
5026            root: dispatch.root,
5027            config_path: &dispatch.cli.config,
5028            output: dispatch.output,
5029            json_style: dispatch.json_style,
5030            no_cache: dispatch.cli.no_cache,
5031            threads: dispatch.threads,
5032            quiet: dispatch.quiet,
5033            allow_remote_extends: dispatch.cli.allow_remote_extends,
5034            from,
5035            to,
5036            eager_only,
5037        });
5038    }
5039    let Some(symbol) = symbol else {
5040        return emit_error(
5041            "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
5042            2,
5043            dispatch.output,
5044        );
5045    };
5046    trace_chain::run_trace(&trace_chain::TraceChainOptions {
5047        root: dispatch.root,
5048        config_path: &dispatch.cli.config,
5049        output: dispatch.output,
5050        json_style: dispatch.json_style,
5051        no_cache: dispatch.cli.no_cache,
5052        threads: dispatch.threads,
5053        quiet: dispatch.quiet,
5054        allow_remote_extends: dispatch.cli.allow_remote_extends,
5055        target: symbol,
5056        callers,
5057        callees,
5058        depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
5059    })
5060}
5061
5062fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5063    let Command::Security {
5064        subcommand,
5065        runtime_coverage,
5066        min_invocations_hot,
5067        file,
5068        gate,
5069        surface,
5070        path,
5071    } = command
5072    else {
5073        unreachable!("security dispatcher only handles security commands");
5074    };
5075
5076    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5077    {
5078        Ok(scope) => scope.map(|resolved| resolved.absolute),
5079        Err(code) => return code,
5080    };
5081
5082    let gate = gate.map(security::SecurityGateArg::into_mode);
5083    let cli = dispatch.cli;
5084    let (output, _quiet, fail_on_issues) =
5085        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5086    let derived_flags = SecurityDerivedFlagState {
5087        output,
5088        json_style: dispatch.json_style,
5089        ci: cli.ci,
5090        fail_on_issues,
5091        sarif_file: cli.sarif_file.as_deref(),
5092        summary: cli.summary,
5093        explain: cli.explain,
5094        runtime_coverage: runtime_coverage.as_deref(),
5095        min_invocations_hot,
5096        file: file.as_slice(),
5097        gate,
5098        surface,
5099    };
5100    if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
5101        return code;
5102    }
5103
5104    let scoped_files = scoped_security_files(&file, subcommand.as_ref());
5105    run_security_blind_spots_or_default(
5106        dispatch,
5107        &SecurityRunInputs {
5108            scoped_files: &scoped_files,
5109            subcommand: &subcommand,
5110            runtime_coverage: runtime_coverage.as_deref(),
5111            min_invocations_hot,
5112            gate,
5113            surface,
5114            scope,
5115        },
5116        &derived_flags,
5117    )
5118}
5119
5120/// Inputs threaded from the security dispatcher into the run step. Borrows the
5121/// scoped file list and subcommand so they outlive the `SecurityOptions`.
5122struct SecurityRunInputs<'a> {
5123    scoped_files: &'a [PathBuf],
5124    subcommand: &'a Option<SecuritySubcommand>,
5125    runtime_coverage: Option<&'a Path>,
5126    min_invocations_hot: u64,
5127    gate: Option<security::SecurityGateMode>,
5128    surface: bool,
5129    scope: Option<PathBuf>,
5130}
5131
5132/// Build `SecurityOptions` and run either the blind-spots or default analysis.
5133fn run_security_blind_spots_or_default(
5134    dispatch: &DispatchContext<'_>,
5135    inputs: &SecurityRunInputs<'_>,
5136    derived_flags: &SecurityDerivedFlagState<'_>,
5137) -> ExitCode {
5138    let cli = dispatch.cli;
5139    let (output, quiet, fail_on_issues) =
5140        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5141    let opts = security::SecurityOptions {
5142        root: dispatch.root,
5143        config_path: &cli.config,
5144        output,
5145        json_style: dispatch.json_style,
5146        no_cache: cli.no_cache,
5147        threads: dispatch.threads,
5148        quiet,
5149        allow_remote_extends: cli.allow_remote_extends,
5150        fail_on_issues,
5151        sarif_file: cli.sarif_file.as_deref(),
5152        summary: cli.summary,
5153        changed_since: cli.changed_since.as_deref(),
5154        use_shared_diff_index: true,
5155        workspace: cli.workspace.as_deref(),
5156        changed_workspaces: cli.changed_workspaces.as_deref(),
5157        file: inputs.scoped_files,
5158        surface: inputs.surface,
5159        scope: inputs.scope.clone(),
5160        gate: inputs.gate,
5161        runtime_coverage: inputs.runtime_coverage,
5162        min_invocations_hot: inputs.min_invocations_hot,
5163        explain: cli.explain,
5164    };
5165    if matches!(
5166        inputs.subcommand,
5167        Some(SecuritySubcommand::BlindSpots { .. })
5168    ) {
5169        if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
5170            return code;
5171        }
5172        security::run_blind_spots(&opts)
5173    } else {
5174        security::run(&opts)
5175    }
5176}
5177
5178/// Handle `fallow security survivors` as an early return. Returns `Some(code)`
5179/// when the subcommand is `survivors` (validated then run); `None` otherwise.
5180fn try_run_security_survivors(
5181    subcommand: Option<&SecuritySubcommand>,
5182    flags: &SecurityDerivedFlagState<'_>,
5183) -> Option<ExitCode> {
5184    let Some(SecuritySubcommand::Survivors {
5185        candidates,
5186        verdicts,
5187        require_verdict_for_each_candidate,
5188    }) = subcommand
5189    else {
5190        return None;
5191    };
5192    if let Some(code) = validate_security_survivors_flags(flags) {
5193        return Some(code);
5194    }
5195    Some(security::run_survivors(
5196        &security::SecuritySurvivorsOptions {
5197            output: flags.output,
5198            json_style: flags.json_style,
5199            candidates,
5200            verdicts,
5201            require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
5202        },
5203    ))
5204}
5205
5206/// Build the scoped file list, folding in `blind-spots` extra `--file` values.
5207fn scoped_security_files(
5208    file: &[PathBuf],
5209    subcommand: Option<&SecuritySubcommand>,
5210) -> Vec<PathBuf> {
5211    let mut scoped_files = file.to_vec();
5212    if let Some(SecuritySubcommand::BlindSpots {
5213        file: blind_spot_files,
5214    }) = subcommand
5215    {
5216        scoped_files.extend(blind_spot_files.iter().cloned());
5217    }
5218    scoped_files
5219}
5220
5221struct SecurityDerivedFlagState<'a> {
5222    output: fallow_config::OutputFormat,
5223    json_style: json_style::JsonStyle,
5224    ci: bool,
5225    fail_on_issues: bool,
5226    sarif_file: Option<&'a Path>,
5227    summary: bool,
5228    explain: bool,
5229    runtime_coverage: Option<&'a Path>,
5230    min_invocations_hot: u64,
5231    file: &'a [PathBuf],
5232    gate: Option<security::SecurityGateMode>,
5233    surface: bool,
5234}
5235
5236fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5237    let flag = if flags.ci {
5238        Some("--ci")
5239    } else if flags.fail_on_issues {
5240        Some("--fail-on-issues")
5241    } else if flags.sarif_file.is_some() {
5242        Some("--sarif-file")
5243    } else if flags.summary {
5244        Some("--summary")
5245    } else if flags.explain {
5246        Some("--explain")
5247    } else if flags.runtime_coverage.is_some() {
5248        Some("--runtime-coverage")
5249    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5250        Some("--min-invocations-hot")
5251    } else if !flags.file.is_empty() {
5252        Some("--file")
5253    } else if flags.gate.is_some() {
5254        Some("--gate")
5255    } else if flags.surface {
5256        Some("--surface")
5257    } else {
5258        None
5259    }?;
5260    Some(emit_error(
5261        &format!("{flag} is not valid with `fallow security survivors`."),
5262        2,
5263        flags.output,
5264    ))
5265}
5266
5267fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5268    let flag = if flags.ci {
5269        Some("--ci")
5270    } else if flags.fail_on_issues {
5271        Some("--fail-on-issues")
5272    } else if flags.sarif_file.is_some() {
5273        Some("--sarif-file")
5274    } else if flags.summary {
5275        Some("--summary")
5276    } else if flags.explain {
5277        Some("--explain")
5278    } else if flags.runtime_coverage.is_some() {
5279        Some("--runtime-coverage")
5280    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5281        Some("--min-invocations-hot")
5282    } else if flags.gate.is_some() {
5283        Some("--gate")
5284    } else if flags.surface {
5285        Some("--surface")
5286    } else {
5287        None
5288    }?;
5289    Some(emit_error(
5290        &format!("{flag} is not valid with `fallow security blind-spots`."),
5291        2,
5292        flags.output,
5293    ))
5294}
5295
5296fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5297    let Command::Dupes {
5298        mode,
5299        near,
5300        min_tokens,
5301        min_lines,
5302        min_occurrences,
5303        threshold,
5304        skip_local,
5305        ignore_symlinks,
5306        no_ignore_symlinks,
5307        cross_language,
5308        ignore_imports,
5309        no_ignore_imports,
5310        top,
5311        no_fragments,
5312        trace,
5313        path,
5314    } = command
5315    else {
5316        unreachable!("dupes dispatcher only handles dupes commands");
5317    };
5318
5319    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5320    {
5321        Ok(scope) => scope.map(|resolved| resolved.absolute),
5322        Err(code) => return code,
5323    };
5324
5325    dispatch_dupes(
5326        dispatch,
5327        &DupesDispatchArgs {
5328            overrides: DupesOverrides {
5329                mode,
5330                near,
5331                threshold,
5332                min_tokens,
5333                min_lines,
5334                min_occurrences,
5335                skip_local,
5336                ignore_symlinks: resolve_negatable_flag(ignore_symlinks, no_ignore_symlinks),
5337                cross_language,
5338                ignore_imports: resolve_negatable_flag(ignore_imports, no_ignore_imports),
5339            },
5340            top,
5341            no_fragments,
5342            trace,
5343            scope,
5344        },
5345    )
5346}
5347
5348fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
5349    run_agent_command(
5350        dispatch.root,
5351        dispatch.cli.root.is_some(),
5352        subcommand,
5353        dispatch.output,
5354        dispatch.json_style,
5355    )
5356}
5357
5358fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
5359    let Command::Init {
5360        toml,
5361        agents,
5362        hooks,
5363        branch,
5364        decline,
5365    } = command
5366    else {
5367        unreachable!("init dispatcher only handles init commands");
5368    };
5369
5370    init::run_init(&init::InitOptions {
5371        root,
5372        use_toml: toml,
5373        agents,
5374        hooks,
5375        branch: branch.as_deref(),
5376        decline,
5377        quiet,
5378    })
5379}
5380
5381fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5382    let Command::Fix {
5383        dry_run,
5384        yes,
5385        no_create_config,
5386        path,
5387    } = command
5388    else {
5389        unreachable!("fix dispatcher only handles fix commands");
5390    };
5391
5392    let scope = match crate::scope_path::resolve_command_scope(
5393        dispatch.root,
5394        dispatch.output,
5395        path.clone(),
5396    ) {
5397        Ok(scope) => scope.map(|resolved| resolved.absolute),
5398        Err(code) => return code,
5399    };
5400
5401    dispatch_fix(
5402        dispatch,
5403        &FixDispatchArgs {
5404            dry_run: *dry_run,
5405            yes: *yes,
5406            no_create_config: *no_create_config,
5407            scope,
5408        },
5409    )
5410}
5411
5412fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5413    match command {
5414        Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
5415        Command::List {
5416            entry_points,
5417            files,
5418            plugins,
5419            boundaries,
5420            workspaces,
5421            entry_weight,
5422            path,
5423        } => {
5424            let scope = match crate::scope_path::resolve_command_scope(
5425                dispatch.root,
5426                dispatch.output,
5427                path.clone(),
5428            ) {
5429                Ok(scope) => scope.map(|resolved| resolved.absolute),
5430                Err(code) => return code,
5431            };
5432            dispatch_list(
5433                dispatch,
5434                &ListDispatchArgs {
5435                    entry_points: *entry_points,
5436                    files: *files,
5437                    plugins: *plugins,
5438                    boundaries: *boundaries,
5439                    workspaces: *workspaces,
5440                    entry_weight: *entry_weight,
5441                    scope,
5442                },
5443            )
5444        }
5445        _ => unreachable!("list dispatcher only handles list commands"),
5446    }
5447}
5448
5449fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
5450    let Command::Migrate {
5451        toml,
5452        jsonc,
5453        dry_run,
5454        from,
5455    } = command
5456    else {
5457        unreachable!("migrate dispatcher only handles migrate commands");
5458    };
5459
5460    migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
5461}
5462
5463fn dispatch_license_command(
5464    subcommand: LicenseCli,
5465    output: fallow_config::OutputFormat,
5466    json_style: json_style::JsonStyle,
5467) -> ExitCode {
5468    license::run(&map_license_subcommand(subcommand), output, json_style)
5469}
5470
5471fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
5472    match subcommand {
5473        CiTemplateCli::Gitlab { vendor, force } => {
5474            ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
5475                vendor_dir: vendor,
5476                force,
5477            })
5478        }
5479    }
5480}
5481
5482fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
5483    let cli = dispatch.cli;
5484    coverage::run(
5485        map_coverage_subcommand(subcommand, cli.explain, cli.changed_since.as_deref()),
5486        &coverage::RunContext {
5487            root: dispatch.root,
5488            config_path: &cli.config,
5489            output: dispatch.output,
5490            json_style: dispatch.json_style,
5491            quiet: dispatch.quiet,
5492            no_cache: cli.no_cache,
5493            threads: dispatch.threads,
5494            explain: cli.explain,
5495            allow_remote_extends: cli.allow_remote_extends,
5496        },
5497    )
5498}
5499
5500fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5501    let Command::Health {
5502        max_cyclomatic,
5503        max_cognitive,
5504        max_crap,
5505        top,
5506        sort,
5507        complexity,
5508        complexity_breakdown,
5509        file_scores,
5510        coverage_gaps,
5511        hotspots,
5512        ownership,
5513        ownership_emails,
5514        targets,
5515        type_coupling,
5516        css,
5517        effort,
5518        score,
5519        min_score,
5520        min_severity,
5521        report_only,
5522        since,
5523        min_commits,
5524        save_snapshot,
5525        trend,
5526        trend_from,
5527        coverage,
5528        coverage_root,
5529        runtime_coverage,
5530        min_invocations_hot,
5531        min_observation_volume,
5532        low_traffic_threshold,
5533        path,
5534    } = command
5535    else {
5536        unreachable!("health dispatcher only handles health commands");
5537    };
5538
5539    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5540    {
5541        Ok(scope) => scope.map(|resolved| resolved.absolute),
5542        Err(code) => return code,
5543    };
5544
5545    let ownership = ownership || ownership_emails.is_some();
5546    let hotspots = hotspots || ownership;
5547    let args = HealthDispatchArgs {
5548        max_cyclomatic,
5549        max_cognitive,
5550        max_crap,
5551        top,
5552        sort,
5553        complexity,
5554        complexity_breakdown,
5555        file_scores,
5556        coverage_gaps,
5557        hotspots,
5558        ownership,
5559        ownership_emails: ownership_emails.map(EmailModeArg::to_config),
5560        targets,
5561        type_coupling,
5562        css,
5563        effort,
5564        score,
5565        min_score,
5566        min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5567        report_only,
5568        since: since.as_deref(),
5569        min_commits,
5570        save_snapshot: save_snapshot.as_ref(),
5571        trend: trend || trend_from.is_some(),
5572        trend_from: trend_from.as_deref(),
5573        fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5574        fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5575        coverage: coverage.as_deref(),
5576        coverage_root: coverage_root.as_deref(),
5577        runtime_coverage: runtime_coverage.as_deref(),
5578        min_invocations_hot,
5579        min_observation_volume,
5580        low_traffic_threshold,
5581        scope,
5582    };
5583    dispatch_health(dispatch, &args)
5584}
5585
5586fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5587    let Command::SetupHooks {
5588        agent,
5589        dry_run,
5590        force,
5591        user,
5592        gitignore_claude,
5593        uninstall,
5594    } = command
5595    else {
5596        unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5597    };
5598
5599    eprintln!(
5600        "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5601    );
5602    setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5603        root: dispatch.root,
5604        agent: *agent,
5605        dry_run: *dry_run,
5606        force: *force,
5607        user: *user,
5608        home: None,
5609        gitignore_claude: *gitignore_claude,
5610        uninstall: *uninstall,
5611    })
5612}
5613
5614fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5615    let Command::Audit {
5616        production_dead_code,
5617        production_health,
5618        production_dupes,
5619        dead_code_baseline,
5620        health_baseline,
5621        dupes_baseline,
5622        max_crap,
5623        coverage,
5624        coverage_root,
5625        no_css,
5626        css_deep,
5627        no_css_deep,
5628        gate,
5629        runtime_coverage,
5630        min_invocations_hot,
5631        gate_marker,
5632        brief,
5633        max_decisions,
5634        walkthrough_guide,
5635        walkthrough_file,
5636        walkthrough,
5637        mark_viewed,
5638        show_cleared,
5639        show_deprioritized,
5640        path,
5641    } = command
5642    else {
5643        unreachable!("audit dispatcher only handles audit commands");
5644    };
5645
5646    // The walkthrough flags imply the brief path (the guide digest + the
5647    // graph-snapshot pin are brief-path data).
5648    let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5649
5650    let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5651    {
5652        Ok(scope) => scope.map(|resolved| resolved.absolute),
5653        Err(code) => return code,
5654    };
5655
5656    dispatch_audit(
5657        dispatch,
5658        &AuditDispatchArgs {
5659            production_dead_code,
5660            production_health,
5661            production_dupes,
5662            dead_code_baseline,
5663            health_baseline,
5664            dupes_baseline,
5665            max_crap,
5666            coverage,
5667            coverage_root,
5668            no_css,
5669            css_deep,
5670            no_css_deep,
5671            gate,
5672            runtime_coverage,
5673            min_invocations_hot,
5674            gate_marker,
5675            brief,
5676            max_decisions,
5677            walkthrough_guide,
5678            walkthrough_file,
5679            walkthrough,
5680            mark_viewed,
5681            show_cleared,
5682            show_deprioritized,
5683            scope,
5684        },
5685    )
5686}
5687
5688fn dispatch_audit_cache_command(
5689    dispatch: &DispatchContext<'_>,
5690    subcommand: &AuditCacheCli,
5691) -> ExitCode {
5692    match subcommand {
5693        AuditCacheCli::Remove { dry_run, yes } => {
5694            if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5695                return emit_error(
5696                    "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5697                    2,
5698                    dispatch.output,
5699                );
5700            }
5701            match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5702                Ok(report) => {
5703                    let action = if *dry_run { "would remove" } else { "removed" };
5704                    if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5705                        let value = serde_json::json!({
5706                            "kind": "audit-cache-remove",
5707                            "schema_version": 1,
5708                            "command": "audit-cache remove",
5709                            "root": dispatch.root,
5710                            "dry_run": report.dry_run,
5711                            "found": report.found,
5712                            "would_remove": report.found.saturating_sub(report.skipped),
5713                            "removed": report.removed,
5714                            "skipped": report.skipped,
5715                            "complete": report.skipped == 0,
5716                        });
5717                        let output_code = report::emit_report_json(
5718                            &value,
5719                            "audit cache removal",
5720                            dispatch.json_style,
5721                        );
5722                        if output_code != ExitCode::SUCCESS {
5723                            return output_code;
5724                        }
5725                    } else if !dispatch.quiet {
5726                        println!(
5727                            "audit cache: {action} {}, skipped {} for {}",
5728                            if *dry_run {
5729                                report.found.saturating_sub(report.skipped)
5730                            } else {
5731                                report.removed
5732                            },
5733                            report.skipped,
5734                            dispatch.root.display(),
5735                        );
5736                    }
5737                    if report.skipped == 0 {
5738                        ExitCode::SUCCESS
5739                    } else {
5740                        ExitCode::from(2)
5741                    }
5742                }
5743                Err(error) => emit_error(
5744                    &format!(
5745                        "failed to remove audit caches for {}: {error}",
5746                        dispatch.root.display()
5747                    ),
5748                    2,
5749                    dispatch.output,
5750                ),
5751            }
5752        }
5753        AuditCacheCli::Prune {
5754            dry_run,
5755            max_age_days,
5756        } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5757            root: dispatch.root,
5758            config_path: dispatch.cli.config.as_ref(),
5759            allow_remote_extends: dispatch.cli.allow_remote_extends,
5760            dry_run: *dry_run,
5761            max_age_days: *max_age_days,
5762            output: dispatch.output,
5763            json_style: dispatch.json_style,
5764            quiet: dispatch.quiet,
5765        }),
5766    }
5767}
5768
5769fn dispatch_baselines_command(
5770    dispatch: &DispatchContext<'_>,
5771    subcommand: &BaselinesCli,
5772) -> ExitCode {
5773    let BaselinesCli::Prune {
5774        check,
5775        dead_code_baseline,
5776        health_baseline,
5777        dupes_baseline,
5778        coverage,
5779        coverage_root,
5780    } = subcommand;
5781    let cli = dispatch.cli;
5782    let root = dispatch.root;
5783    let output = dispatch.output;
5784    if let Some(flag) = baselines_prune_narrowing_flag(cli) {
5785        return emit_error(
5786            &format!(
5787                "`fallow baselines prune` always analyzes the whole project, so it does not accept `{flag}`. A narrowed run would remove valid entries."
5788            ),
5789            2,
5790            output,
5791        );
5792    }
5793    if let Some(flag) = cli_global_baseline_flag(cli) {
5794        return emit_error(
5795            &format!(
5796                "`fallow baselines prune` does not accept `{flag}`. Name the files with --dead-code-baseline, --health-baseline and --dupes-baseline, or with `audit.*Baseline` in the config."
5797            ),
5798            2,
5799            output,
5800        );
5801    }
5802    let config = match load_config(
5803        root,
5804        &cli.config,
5805        LoadConfigArgs {
5806            output,
5807            no_cache: cli.no_cache,
5808            threads: dispatch.threads,
5809            production: false,
5810            quiet: dispatch.quiet,
5811            allow_remote_extends: cli.allow_remote_extends,
5812        },
5813    ) {
5814        Ok(config) => config,
5815        Err(code) => return code,
5816    };
5817    let targets = baselines::BaselineTargets {
5818        dead_code: resolve_audit_baseline_path(
5819            root,
5820            dead_code_baseline.as_deref(),
5821            config.audit.dead_code_baseline.as_deref(),
5822        ),
5823        health: resolve_audit_baseline_path(
5824            root,
5825            health_baseline.as_deref(),
5826            config.audit.health_baseline.as_deref(),
5827        ),
5828        dupes: resolve_audit_baseline_path(
5829            root,
5830            dupes_baseline.as_deref(),
5831            config.audit.dupes_baseline.as_deref(),
5832        ),
5833    };
5834    if targets.dead_code.is_none() && targets.health.is_none() && targets.dupes.is_none() {
5835        return emit_error(
5836            "no baseline to prune. Set `audit.deadCodeBaseline`, `audit.healthBaseline` or `audit.dupesBaseline` in the config, or pass --dead-code-baseline, --health-baseline or --dupes-baseline",
5837            2,
5838            output,
5839        );
5840    }
5841    let production = match resolve_production_modes(cli, root, output, false, false, false) {
5842        Ok(production) => production,
5843        Err(code) => return code,
5844    };
5845    let coverage_inputs = match resolve_coverage_inputs(
5846        coverage.as_deref(),
5847        coverage_root.as_deref(),
5848        output,
5849        || Ok(config.health),
5850    ) {
5851        Ok(inputs) => inputs,
5852        Err(code) => return code,
5853    };
5854    baselines::run_baselines_prune(&baselines::BaselinesPruneOptions {
5855        root,
5856        config_path: &cli.config,
5857        output,
5858        json_style: dispatch.json_style,
5859        no_cache: cli.no_cache,
5860        threads: dispatch.threads,
5861        quiet: dispatch.quiet,
5862        allow_remote_extends: cli.allow_remote_extends,
5863        check: *check,
5864        targets,
5865        production,
5866        coverage: coverage_inputs.coverage.as_deref(),
5867        coverage_root: coverage_inputs.coverage_root.as_deref(),
5868        regression_opts: dispatch.regression_opts(true),
5869    })
5870}
5871
5872/// The first flag or environment variable that would narrow the prune run.
5873fn baselines_prune_narrowing_flag(cli: &Cli) -> Option<&'static str> {
5874    [
5875        (cli.changed_since.is_some(), "--changed-since"),
5876        (cli.workspace.is_some(), "--workspace"),
5877        (cli.changed_workspaces.is_some(), "--changed-workspaces"),
5878        (cli.diff_file.is_some(), "--diff-file"),
5879        (cli.diff_stdin, "--diff-stdin"),
5880        (
5881            std::env::var_os("FALLOW_DIFF_FILE").is_some_and(|value| !value.is_empty()),
5882            "FALLOW_DIFF_FILE",
5883        ),
5884        (cli.production, "--production"),
5885    ]
5886    .into_iter()
5887    .find_map(|(present, flag)| present.then_some(flag))
5888}
5889
5890fn dispatch_flags_command(
5891    dispatch: &DispatchContext<'_>,
5892    top: Option<usize>,
5893    retirement: Option<flags::RetirementArgs>,
5894) -> ExitCode {
5895    let cli = dispatch.cli;
5896    let root = dispatch.root;
5897    let output = dispatch.output;
5898    let quiet = dispatch.quiet;
5899    let threads = dispatch.threads;
5900    let production = match resolve_production_modes(cli, root, output, false, false, false) {
5901        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5902        Err(code) => return code,
5903    };
5904    flags::run_flags(&flags::FlagsOptions {
5905        root,
5906        config_path: &cli.config,
5907        output,
5908        json_style: dispatch.json_style,
5909        no_cache: cli.no_cache,
5910        threads,
5911        quiet,
5912        allow_remote_extends: cli.allow_remote_extends,
5913        production,
5914        workspace: cli.workspace.as_deref(),
5915        changed_workspaces: cli.changed_workspaces.as_deref(),
5916        changed_since: cli.changed_since.as_deref(),
5917        explain: cli.explain,
5918        top,
5919        retirement,
5920        regression: dispatch.regression_opts(false),
5921        regression_flag: first_regression_flag(cli),
5922    })
5923}
5924
5925/// The first regression-gate option on the command line, if any.
5926fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5927    [
5928        (cli.fail_on_regression, "--fail-on-regression"),
5929        (cli.regression_baseline.is_some(), "--regression-baseline"),
5930        (
5931            cli.save_regression_baseline.is_some(),
5932            "--save-regression-baseline",
5933        ),
5934        (cli.tolerance != "0", "--tolerance"),
5935    ]
5936    .into_iter()
5937    .find_map(|(used, flag)| used.then_some(flag))
5938}
5939
5940fn dispatch_suppressions_command(
5941    dispatch: &DispatchContext<'_>,
5942    file: &[std::path::PathBuf],
5943) -> ExitCode {
5944    let cli = dispatch.cli;
5945    let root = dispatch.root;
5946    let output = dispatch.output;
5947    let production = match resolve_production_modes(cli, root, output, false, false, false) {
5948        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5949        Err(code) => return code,
5950    };
5951    suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5952        root,
5953        config_path: &cli.config,
5954        output,
5955        json_style: dispatch.json_style,
5956        no_cache: cli.no_cache,
5957        threads: dispatch.threads,
5958        quiet: dispatch.quiet,
5959        allow_remote_extends: cli.allow_remote_extends,
5960        production,
5961        workspace: cli.workspace.as_deref(),
5962        changed_workspaces: cli.changed_workspaces.as_deref(),
5963        changed_since: cli.changed_since.as_deref(),
5964        file,
5965    })
5966}
5967
5968fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5969    guard::run_guard(&guard::GuardOptions {
5970        root: dispatch.root,
5971        config_path: &dispatch.cli.config,
5972        output: dispatch.output,
5973        json_style: dispatch.json_style,
5974        quiet: dispatch.quiet,
5975        allow_remote_extends: dispatch.cli.allow_remote_extends,
5976        files,
5977    })
5978}
5979
5980fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5981    let ctx = rule_pack::RulePackContext {
5982        root: dispatch.root,
5983        config_path: &dispatch.cli.config,
5984        output: dispatch.output,
5985        json_style: dispatch.json_style,
5986        quiet: dispatch.quiet,
5987        no_cache: dispatch.cli.no_cache,
5988        threads: Some(dispatch.threads),
5989        allow_remote_extends: dispatch.cli.allow_remote_extends,
5990    };
5991    rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5992}
5993
5994fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5995    match subcommand {
5996        RulePackCli::Init {
5997            name,
5998            template,
5999            dir,
6000            no_config,
6001        } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
6002            name,
6003            template,
6004            dir,
6005            no_config,
6006        }),
6007        RulePackCli::List => rule_pack::RulePackSubcommand::List,
6008        RulePackCli::Test { pack } => {
6009            rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
6010        }
6011        RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
6012    }
6013}
6014
6015fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
6016    match sub {
6017        LicenseCli::Activate {
6018            jwt,
6019            from_file,
6020            stdin,
6021            trial,
6022            email,
6023        } => license::LicenseSubcommand::Activate(license::ActivateArgs {
6024            raw_jwt: jwt,
6025            from_file,
6026            from_stdin: stdin,
6027            trial,
6028            email,
6029        }),
6030        LicenseCli::Status => license::LicenseSubcommand::Status,
6031        LicenseCli::Refresh { api_key } => {
6032            license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
6033        }
6034        LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
6035    }
6036}
6037
6038fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
6039    match sub {
6040        TelemetryCli::Status => telemetry::TelemetryCommand::Status,
6041        TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
6042        TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
6043        TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
6044    }
6045}
6046
6047fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
6048    match sub {
6049        command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
6050        command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
6051        command @ CiCli::PostReview { .. } => map_ci_post_review(command),
6052        command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
6053        command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
6054    }
6055}
6056
6057fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
6058    let CiCli::PlanPrComment {
6059        body,
6060        marker_id,
6061        clean,
6062        existing_comment_id,
6063        existing_body,
6064    } = command
6065    else {
6066        unreachable!("ci plan-pr-comment mapper called with different variant");
6067    };
6068
6069    ci::CiCommand::PlanPrComment {
6070        body,
6071        marker_id,
6072        clean,
6073        existing_comment_id,
6074        existing_body,
6075    }
6076}
6077
6078fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
6079    let CiCli::PostPrComment {
6080        provider,
6081        pr,
6082        mr,
6083        body,
6084        envelope,
6085        marker_id,
6086        clean,
6087        repo,
6088        project_id,
6089        api_url,
6090        dry_run,
6091    } = command
6092    else {
6093        unreachable!("ci post-pr-comment mapper called with different variant");
6094    };
6095
6096    ci::CiCommand::PostPrComment {
6097        provider: map_ci_provider(provider),
6098        target: pr.or(mr),
6099        body,
6100        envelope,
6101        marker_id,
6102        clean,
6103        repo,
6104        project_id,
6105        api_url,
6106        dry_run,
6107    }
6108}
6109
6110fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
6111    let CiCli::PostReview {
6112        provider,
6113        pr,
6114        mr,
6115        envelope,
6116        repo,
6117        project_id,
6118        api_url,
6119        dry_run,
6120    } = command
6121    else {
6122        unreachable!("ci post-review mapper called with different variant");
6123    };
6124
6125    ci::CiCommand::PostReview {
6126        provider: map_ci_provider(provider),
6127        target: pr.or(mr),
6128        envelope,
6129        repo,
6130        project_id,
6131        api_url,
6132        dry_run,
6133    }
6134}
6135
6136fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
6137    let CiCli::PostCheckRun {
6138        provider,
6139        decision,
6140        repo,
6141        head_sha,
6142        api_url,
6143        split_gates,
6144        dry_run,
6145    } = command
6146    else {
6147        unreachable!("ci post-check-run mapper called with different variant");
6148    };
6149
6150    ci::CiCommand::PostCheckRun {
6151        provider: map_ci_provider(provider),
6152        decision,
6153        repo,
6154        head_sha,
6155        api_url,
6156        split_gates,
6157        dry_run,
6158    }
6159}
6160
6161fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
6162    let CiCli::ReconcileReview {
6163        provider,
6164        pr,
6165        mr,
6166        envelope,
6167        repo,
6168        project_id,
6169        api_url,
6170        dry_run,
6171    } = command
6172    else {
6173        unreachable!("ci reconcile-review mapper called with different variant");
6174    };
6175
6176    ci::CiCommand::ReconcileReview {
6177        provider: map_ci_provider(provider),
6178        target: pr.or(mr),
6179        envelope,
6180        repo,
6181        project_id,
6182        api_url,
6183        dry_run,
6184    }
6185}
6186
6187fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
6188    match provider {
6189        CiProviderArg::Github => ci::CiProvider::Github,
6190        CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
6191    }
6192}
6193
6194fn map_coverage_subcommand(
6195    sub: &CoverageCli,
6196    explain: bool,
6197    base: Option<&str>,
6198) -> coverage::CoverageSubcommand {
6199    match sub {
6200        CoverageCli::Setup {
6201            yes,
6202            non_interactive,
6203            json,
6204        } => map_coverage_setup(*yes, *non_interactive, *json, explain),
6205        CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
6206        CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
6207        CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
6208        CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
6209        CoverageCli::ReviewPacket {
6210            api_key,
6211            api_endpoint,
6212            repo,
6213            project_id,
6214            coverage_period,
6215            commit_sha,
6216            files,
6217            functions,
6218        } => coverage::CoverageSubcommand::ReviewPacket(coverage::ReviewPacketArgs {
6219            api_key: api_key.clone(),
6220            api_endpoint: api_endpoint.clone(),
6221            repo: repo.clone(),
6222            project_id: project_id.clone(),
6223            coverage_period: *coverage_period,
6224            commit_sha: commit_sha.clone(),
6225            files: files.clone(),
6226            functions: functions.clone(),
6227            base: base.map(str::to_owned),
6228        }),
6229        CoverageCli::DeploymentChanges {
6230            api_key,
6231            api_endpoint,
6232            repo,
6233            sha,
6234            change,
6235            limit,
6236            cursor,
6237        } => coverage::CoverageSubcommand::DeploymentChanges(coverage::DeploymentChangesArgs {
6238            api_key: api_key.clone(),
6239            api_endpoint: api_endpoint.clone(),
6240            repo: repo.clone(),
6241            sha: sha.clone(),
6242            base: base.map(str::to_owned),
6243            change: change.clone(),
6244            limit: *limit,
6245            cursor: cursor.clone(),
6246        }),
6247    }
6248}
6249
6250fn map_coverage_setup(
6251    yes: bool,
6252    non_interactive: bool,
6253    json: bool,
6254    explain: bool,
6255) -> coverage::CoverageSubcommand {
6256    coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
6257        yes,
6258        non_interactive: non_interactive || json,
6259        json,
6260        explain,
6261    })
6262}
6263
6264fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6265    let CoverageCli::Analyze {
6266        runtime_coverage,
6267        cloud,
6268        api_key,
6269        api_endpoint,
6270        repo,
6271        project_id,
6272        coverage_period,
6273        environment,
6274        commit_sha,
6275        production,
6276        min_invocations_hot,
6277        min_observation_volume,
6278        low_traffic_threshold,
6279        top,
6280        blast_radius,
6281        importance,
6282        debug_unmatched,
6283    } = sub
6284    else {
6285        unreachable!("coverage analyze mapper called with non-analyze variant");
6286    };
6287    coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
6288        runtime_coverage: runtime_coverage.clone(),
6289        cloud: *cloud,
6290        api_key: api_key.clone(),
6291        api_endpoint: api_endpoint.clone(),
6292        repo: repo.clone(),
6293        project_id: project_id.clone(),
6294        coverage_period: *coverage_period,
6295        environment: environment.clone(),
6296        commit_sha: commit_sha.clone(),
6297        production: *production,
6298        min_invocations_hot: *min_invocations_hot,
6299        min_observation_volume: *min_observation_volume,
6300        low_traffic_threshold: *low_traffic_threshold,
6301        top: *top,
6302        blast_radius: *blast_radius,
6303        importance: *importance,
6304        debug_unmatched: *debug_unmatched,
6305    })
6306}
6307
6308fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6309    let CoverageCli::UploadInventory {
6310        api_key,
6311        api_endpoint,
6312        project_id,
6313        git_sha,
6314        allow_dirty,
6315        exclude_paths,
6316        path_prefix,
6317        dry_run,
6318        with_callers,
6319        ignore_upload_errors,
6320    } = sub
6321    else {
6322        unreachable!("coverage inventory mapper called with non-inventory variant");
6323    };
6324    coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
6325        api_key: api_key.clone(),
6326        api_endpoint: api_endpoint.clone(),
6327        project_id: project_id.clone(),
6328        git_sha: git_sha.clone(),
6329        allow_dirty: *allow_dirty,
6330        exclude_paths: exclude_paths.clone(),
6331        path_prefix: path_prefix.clone(),
6332        dry_run: *dry_run,
6333        with_callers: *with_callers,
6334        ignore_upload_errors: *ignore_upload_errors,
6335    })
6336}
6337
6338fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6339    let CoverageCli::UploadSourceMaps {
6340        dir,
6341        include,
6342        exclude,
6343        repo,
6344        git_sha,
6345        endpoint,
6346        strip_path,
6347        dry_run,
6348        concurrency,
6349        fail_fast,
6350    } = sub
6351    else {
6352        unreachable!("coverage source-map mapper called with non-source-map variant");
6353    };
6354    coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
6355        dir: dir.clone(),
6356        include: include.clone(),
6357        exclude: exclude.clone(),
6358        repo: repo.clone(),
6359        git_sha: git_sha.clone(),
6360        endpoint: endpoint.clone(),
6361        strip_path: *strip_path,
6362        dry_run: *dry_run,
6363        concurrency: *concurrency,
6364        fail_fast: *fail_fast,
6365    })
6366}
6367
6368fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6369    let CoverageCli::UploadStaticFindings {
6370        api_key,
6371        api_endpoint,
6372        project_id,
6373        git_sha,
6374        allow_dirty,
6375        dry_run,
6376        ignore_upload_errors,
6377    } = sub
6378    else {
6379        unreachable!("coverage static-findings mapper called with non-static variant");
6380    };
6381    coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
6382        api_key: api_key.clone(),
6383        api_endpoint: api_endpoint.clone(),
6384        project_id: project_id.clone(),
6385        git_sha: git_sha.clone(),
6386        allow_dirty: *allow_dirty,
6387        dry_run: *dry_run,
6388        ignore_upload_errors: *ignore_upload_errors,
6389    })
6390}
6391
6392struct CheckDispatchArgs {
6393    filters: IssueFilters,
6394    trace_opts: TraceOptions,
6395    include_dupes: bool,
6396    type_aware: Option<bool>,
6397    type_aware_project: Vec<std::path::PathBuf>,
6398    type_aware_require: Option<TypeAwareRequireArg>,
6399    top: Option<usize>,
6400    file: Vec<std::path::PathBuf>,
6401    scope: Option<std::path::PathBuf>,
6402    finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
6403    surface: check::CheckSurface,
6404}
6405
6406#[derive(Clone)]
6407struct ListDispatchArgs {
6408    entry_points: bool,
6409    files: bool,
6410    plugins: bool,
6411    boundaries: bool,
6412    workspaces: bool,
6413    entry_weight: bool,
6414    scope: Option<std::path::PathBuf>,
6415}
6416
6417impl ListDispatchArgs {
6418    fn workspaces() -> Self {
6419        Self {
6420            entry_points: false,
6421            files: false,
6422            plugins: false,
6423            boundaries: false,
6424            workspaces: true,
6425            entry_weight: false,
6426            scope: None,
6427        }
6428    }
6429}
6430
6431fn dispatch_viz(
6432    dispatch: &DispatchContext<'_>,
6433    output_path: Option<&std::path::Path>,
6434    no_open: bool,
6435    format: viz::VizFormat,
6436) -> ExitCode {
6437    let cli = dispatch.cli;
6438    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6439        Ok(production) => production,
6440        Err(code) => return code,
6441    };
6442    viz::run_viz(&viz::VizOptions {
6443        root: dispatch.root,
6444        config_path: &cli.config,
6445        no_cache: cli.no_cache,
6446        threads: dispatch.threads,
6447        quiet: dispatch.quiet,
6448        production,
6449        allow_remote_extends: cli.allow_remote_extends,
6450        output_path,
6451        no_open,
6452        format,
6453    })
6454}
6455
6456fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
6457    let cli = dispatch.cli;
6458    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6459        Ok(production) => production,
6460        Err(code) => return code,
6461    };
6462    watch::run_watch(&watch::WatchOptions {
6463        root: dispatch.root,
6464        config_path: &cli.config,
6465        output: dispatch.output,
6466        json_style: dispatch.json_style,
6467        no_cache: cli.no_cache,
6468        threads: dispatch.threads,
6469        quiet: dispatch.quiet,
6470        allow_remote_extends: cli.allow_remote_extends,
6471        production,
6472        clear_screen: !no_clear,
6473        explain: cli.explain,
6474        include_entry_exports: cli.include_entry_exports,
6475        show_cascade: cli.show_cascade,
6476        type_aware: cli.type_aware_override(),
6477        type_aware_projects: &cli.type_aware_project,
6478        type_aware_require: cli.type_aware_require.map(Into::into),
6479    })
6480}
6481
6482struct FixDispatchArgs {
6483    dry_run: bool,
6484    yes: bool,
6485    no_create_config: bool,
6486    scope: Option<std::path::PathBuf>,
6487}
6488
6489fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
6490    let cli = dispatch.cli;
6491    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6492        Ok(production) => production,
6493        Err(code) => return code,
6494    };
6495    fix::run_fix(&fix::FixOptions {
6496        root: dispatch.root,
6497        config_path: &cli.config,
6498        output: dispatch.output,
6499        json_style: dispatch.json_style,
6500        no_cache: cli.no_cache,
6501        threads: dispatch.threads,
6502        quiet: dispatch.quiet,
6503        emit_output: true,
6504        allow_remote_extends: cli.allow_remote_extends,
6505        dry_run: args.dry_run,
6506        yes: args.yes,
6507        production,
6508        no_create_config: args.no_create_config,
6509        type_aware: cli.type_aware_override(),
6510        type_aware_projects: &cli.type_aware_project,
6511        type_aware_require: cli.type_aware_require.map(Into::into),
6512        scope: args.scope.clone(),
6513        show_cascade: cli.show_cascade,
6514    })
6515}
6516
6517fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
6518    let cli = dispatch.cli;
6519    let (save_regression_file, save_to_config) = regression_save_targets(cli);
6520    // Only the entry weight gate reads the global `--tolerance`, so a plain
6521    // listing does not fail on a value it never uses.
6522    let entry_weight_gate = if args.entry_weight {
6523        let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
6524            Ok(tolerance) => tolerance,
6525            Err(message) => {
6526                return emit_error(
6527                    &format!("invalid --tolerance: {message}"),
6528                    2,
6529                    dispatch.output,
6530                );
6531            }
6532        };
6533        Some(regression::EntryWeightGate {
6534            fail_on_regression: cli.fail_on_regression,
6535            tolerance,
6536            baseline_file: cli.regression_baseline.as_deref(),
6537            save_file: save_regression_file.as_deref(),
6538            save_to_config,
6539        })
6540    } else {
6541        None
6542    };
6543    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6544        Ok(production) => production,
6545        Err(code) => return code,
6546    };
6547    list::run_list(&ListOptions {
6548        root: dispatch.root,
6549        config_path: &cli.config,
6550        output: dispatch.output,
6551        json_style: dispatch.json_style,
6552        threads: dispatch.threads,
6553        no_cache: cli.no_cache,
6554        entry_points: args.entry_points,
6555        files: args.files,
6556        plugins: args.plugins,
6557        boundaries: args.boundaries,
6558        workspaces: args.workspaces,
6559        entry_weight: args.entry_weight,
6560        entry_weight_gate,
6561        production,
6562        allow_remote_extends: cli.allow_remote_extends,
6563        scope: args.scope.clone(),
6564    })
6565}
6566
6567fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
6568    let cli = dispatch.cli;
6569    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6570        Ok(production) => production,
6571        Err(code) => return code,
6572    };
6573    if let Some(code) = validate_type_aware_check_options(dispatch, args) {
6574        return code;
6575    }
6576    let growth_targets = baseline_growth::targets(&[(
6577        cli.baseline.as_deref(),
6578        fallow_engine::baseline::BaselineKind::DeadCode,
6579    )]);
6580    baseline_growth::run_gated(
6581        &dispatch.growth_ctx(baseline_growth::GrowthOwner::DeadCode),
6582        &growth_targets,
6583        || dispatch_check_run(dispatch, args, production),
6584    )
6585}
6586
6587fn dispatch_check_run(
6588    dispatch: &DispatchContext<'_>,
6589    args: &CheckDispatchArgs,
6590    production: bool,
6591) -> ExitCode {
6592    let cli = dispatch.cli;
6593    let (output, quiet, fail_on_issues) =
6594        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6595    check::run_check(&CheckOptions {
6596        root: dispatch.root,
6597        config_path: &cli.config,
6598        output,
6599        json_style: dispatch.json_style,
6600        no_cache: cli.no_cache,
6601        threads: dispatch.threads,
6602        quiet,
6603        allow_remote_extends: cli.allow_remote_extends,
6604        fail_on_issues,
6605        filters: &args.filters,
6606        changed_since: cli.changed_since.as_deref(),
6607        change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6608        no_package_baselines: cli.no_package_baselines,
6609        diff_index: None,
6610        use_shared_diff_index: true,
6611        baseline: cli.baseline.as_deref(),
6612        baseline_flag: "--baseline",
6613        bare_run: false,
6614        save_baseline: cli.save_baseline.as_deref(),
6615        fail_on_stale_baseline: cli.fail_on_stale_baseline,
6616        sarif_file: cli.sarif_file.as_deref(),
6617        production,
6618        production_override: Some(production),
6619        workspace: cli.workspace.as_deref(),
6620        changed_workspaces: cli.changed_workspaces.as_deref(),
6621        group_by: cli.group_by,
6622        include_dupes: args.include_dupes,
6623        type_aware: args.type_aware,
6624        type_aware_config_override: None,
6625        type_aware_projects: &args.type_aware_project,
6626        type_aware_require: args.type_aware_require.map(Into::into),
6627        trace_opts: &args.trace_opts,
6628        explain: cli.explain,
6629        top: args.top,
6630        file: &args.file,
6631        scope: args.scope.clone(),
6632        finding_ids: args.finding_ids.as_ref(),
6633        include_entry_exports: cli.include_entry_exports,
6634        show_cascade: cli.show_cascade,
6635        fail_on_parse_error: cli.fail_on_parse_error,
6636        summary: cli.summary,
6637        regression_opts: dispatch.regression_opts(
6638            cli.changed_since.is_some()
6639                || cli.workspace.is_some()
6640                || cli.changed_workspaces.is_some()
6641                || !args.file.is_empty()
6642                || args.scope.is_some(),
6643        ),
6644        retain_modules_for_health: false,
6645        defer_performance: true,
6646        analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
6647        explain_skipped: cli.explain_skipped,
6648        surface: args.surface,
6649    })
6650}
6651
6652fn validate_type_aware_check_options(
6653    dispatch: &DispatchContext<'_>,
6654    args: &CheckDispatchArgs,
6655) -> Option<ExitCode> {
6656    let output = dispatch.output;
6657    if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
6658        return Some(emit_error(
6659            "--type-aware-project requires --type-aware",
6660            2,
6661            output,
6662        ));
6663    }
6664    if args.type_aware_require.is_some() && args.type_aware != Some(true) {
6665        return Some(emit_error(
6666            "--type-aware-require requires --type-aware",
6667            2,
6668            output,
6669        ));
6670    }
6671    if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
6672        return Some(emit_error(
6673            "--symbol-impact requires --type-aware",
6674            2,
6675            output,
6676        ));
6677    }
6678    let focused_output = args.trace_opts.trace_export.is_some()
6679        || args.trace_opts.trace_file.is_some()
6680        || args.trace_opts.trace_dependency.is_some()
6681        || args.trace_opts.impact_closure.is_some()
6682        || args.trace_opts.symbol_impact.is_some();
6683    if focused_output
6684        && !matches!(
6685            output,
6686            fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
6687        )
6688    {
6689        return Some(emit_error(
6690            "focused trace and impact queries support human and JSON output",
6691            2,
6692            output,
6693        ));
6694    }
6695    if args.type_aware == Some(true)
6696        && !matches!(
6697            output,
6698            fallow_config::OutputFormat::Human
6699                | fallow_config::OutputFormat::Json
6700                | fallow_config::OutputFormat::Sarif
6701                | fallow_config::OutputFormat::Compact
6702                | fallow_config::OutputFormat::Markdown
6703                | fallow_config::OutputFormat::CodeClimate
6704                | fallow_config::OutputFormat::PrCommentGithub
6705                | fallow_config::OutputFormat::PrCommentGitlab
6706                | fallow_config::OutputFormat::ReviewGithub
6707                | fallow_config::OutputFormat::ReviewGitlab
6708        )
6709    {
6710        return Some(emit_error(
6711            "--type-aware supports human, JSON, SARIF, compact, markdown, CodeClimate, PR-comment, and review output; pair presentation formats with the JSON artifact to preserve semantic provenance",
6712            2,
6713            output,
6714        ));
6715    }
6716    None
6717}
6718
6719/// Resolve the three-state `ignoreImports` CLI override from the opt-in /
6720/// opt-out flag pair. clap's `conflicts_with` guarantees the two are never both
6721/// set, so this maps `--no-ignore-imports` -> `Some(false)`, `--ignore-imports`
6722/// -> `Some(true)`, and neither -> `None` (defer to config, which defaults to
6723/// `true`).
6724/// Resolve a `--flag` / `--no-flag` pair: `Some(false)` for the opt-out,
6725/// `Some(true)` for the opt-in, `None` to defer to config.
6726const fn resolve_negatable_flag(opt_in: bool, opt_out: bool) -> Option<bool> {
6727    if opt_out {
6728        Some(false)
6729    } else if opt_in {
6730        Some(true)
6731    } else {
6732        None
6733    }
6734}
6735
6736struct DupesDispatchArgs {
6737    overrides: DupesOverrides,
6738    top: Option<usize>,
6739    no_fragments: bool,
6740    trace: Option<String>,
6741    scope: Option<std::path::PathBuf>,
6742}
6743
6744fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
6745    let cli = dispatch.cli;
6746    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
6747        Ok(production) => production,
6748        Err(code) => return code,
6749    };
6750    let growth_targets = baseline_growth::targets(&[(
6751        cli.baseline.as_deref(),
6752        fallow_engine::baseline::BaselineKind::Dupes,
6753    )]);
6754    baseline_growth::run_gated(
6755        &dispatch.growth_ctx(baseline_growth::GrowthOwner::Dupes),
6756        &growth_targets,
6757        || dispatch_dupes_run(dispatch, args, production),
6758    )
6759}
6760
6761fn dispatch_dupes_run(
6762    dispatch: &DispatchContext<'_>,
6763    args: &DupesDispatchArgs,
6764    production: bool,
6765) -> ExitCode {
6766    let cli = dispatch.cli;
6767    let (output, quiet) = (dispatch.output, dispatch.quiet);
6768    dupes::run_dupes(&DupesOptions {
6769        root: dispatch.root,
6770        config_path: &cli.config,
6771        output,
6772        json_style: dispatch.json_style,
6773        no_cache: cli.no_cache,
6774        threads: dispatch.threads,
6775        quiet,
6776        allow_remote_extends: cli.allow_remote_extends,
6777        overrides: args.overrides,
6778        top: args.top,
6779        baseline_path: cli.baseline.as_deref(),
6780        baseline_flag: "--baseline",
6781        save_baseline_path: cli.save_baseline.as_deref(),
6782        fail_on_stale_baseline: cli.fail_on_stale_baseline,
6783        fail_on_issues: dispatch.fail_on_issues,
6784        production,
6785        production_override: Some(production),
6786        trace: args.trace.as_deref(),
6787        changed_since: cli.changed_since.as_deref(),
6788        diff_index: None,
6789        use_shared_diff_index: true,
6790        changed_files: None,
6791        change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6792        no_package_baselines: cli.no_package_baselines,
6793        workspace: cli.workspace.as_deref(),
6794        changed_workspaces: cli.changed_workspaces.as_deref(),
6795        explain: cli.explain,
6796        explain_skipped: cli.explain_skipped,
6797        summary: cli.summary,
6798        group_by: cli.group_by,
6799        performance: cli.performance,
6800        include_fragments: !args.no_fragments,
6801        retain_unfiltered_report: false,
6802        scope: args.scope.clone(),
6803    })
6804}
6805
6806struct AuditDispatchArgs {
6807    production_dead_code: bool,
6808    production_health: bool,
6809    production_dupes: bool,
6810    dead_code_baseline: Option<PathBuf>,
6811    health_baseline: Option<PathBuf>,
6812    dupes_baseline: Option<PathBuf>,
6813    max_crap: Option<f64>,
6814    coverage: Option<PathBuf>,
6815    coverage_root: Option<PathBuf>,
6816    no_css: bool,
6817    css_deep: bool,
6818    no_css_deep: bool,
6819    gate: Option<AuditGateArg>,
6820    runtime_coverage: Option<PathBuf>,
6821    min_invocations_hot: u64,
6822    gate_marker: Option<String>,
6823    brief: bool,
6824    max_decisions: usize,
6825    /// Emit the agent-contract walkthrough guide instead of the brief body.
6826    walkthrough_guide: bool,
6827    /// Post-validate an agent's judgment JSON from this path against the
6828    /// live graph.
6829    walkthrough_file: Option<PathBuf>,
6830    /// Render the existing walkthrough guide as a staged human/markdown tour.
6831    walkthrough: bool,
6832    /// Changed files to record as VIEWED before rendering the tour.
6833    mark_viewed: Vec<PathBuf>,
6834    /// Expand the Cleared panel (de-prioritized + viewed) in the tour.
6835    show_cleared: bool,
6836    /// Expand the de-prioritized units in the human focus map.
6837    show_deprioritized: bool,
6838    scope: Option<PathBuf>,
6839}
6840
6841struct ResolvedAuditInputs {
6842    audit_cfg: fallow_config::AuditConfig,
6843    cache_dir: PathBuf,
6844    production: ProductionModes,
6845    dead_code_baseline: Option<PathBuf>,
6846    health_baseline: Option<PathBuf>,
6847    dupes_baseline: Option<PathBuf>,
6848    /// Istanbul coverage inputs resolved with the health precedence (flag,
6849    /// env, then `health.coverage` / `health.coverageRoot`), so the head and
6850    /// base passes score from the same map the standalone health run uses.
6851    coverage: Option<PathBuf>,
6852    coverage_root: Option<PathBuf>,
6853}
6854
6855fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6856    let cli = dispatch.cli;
6857    let output = dispatch.output;
6858
6859    if cli.baseline.is_some() || cli.save_baseline.is_some() {
6860        return emit_error(
6861            "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>`)",
6862            2,
6863            output,
6864        );
6865    }
6866
6867    let inputs = match resolve_audit_inputs(dispatch, args) {
6868        Ok(inputs) => inputs,
6869        Err(code) => return code,
6870    };
6871
6872    // The brief and the walkthrough views always exit 0, so the gate stands
6873    // down there and says so.
6874    if cli.fail_on_baseline_growth && audit_renders_brief(args) {
6875        baseline_growth::note_stood_down(
6876            dispatch.growth_flags(),
6877            "the review brief never fails a run",
6878        );
6879        return run_resolved_audit(dispatch, args, &inputs);
6880    }
6881    let growth_targets = baseline_growth::targets(&[
6882        (
6883            inputs.dead_code_baseline.as_deref(),
6884            fallow_engine::baseline::BaselineKind::DeadCode,
6885        ),
6886        (
6887            inputs.health_baseline.as_deref(),
6888            fallow_engine::baseline::BaselineKind::Health,
6889        ),
6890        (
6891            inputs.dupes_baseline.as_deref(),
6892            fallow_engine::baseline::BaselineKind::Dupes,
6893        ),
6894    ]);
6895    baseline_growth::run_gated(
6896        &dispatch.growth_ctx(baseline_growth::GrowthOwner::Audit),
6897        &growth_targets,
6898        || run_resolved_audit(dispatch, args, &inputs),
6899    )
6900}
6901
6902const fn audit_renders_brief(args: &AuditDispatchArgs) -> bool {
6903    args.brief || args.walkthrough_guide || args.walkthrough || args.walkthrough_file.is_some()
6904}
6905
6906fn resolve_audit_inputs(
6907    dispatch: &DispatchContext<'_>,
6908    args: &AuditDispatchArgs,
6909) -> Result<ResolvedAuditInputs, ExitCode> {
6910    let cli = dispatch.cli;
6911    let root = dispatch.root;
6912    let output = dispatch.output;
6913    let config = load_config(
6914        root,
6915        &cli.config,
6916        LoadConfigArgs {
6917            output,
6918            no_cache: cli.no_cache,
6919            threads: dispatch.threads,
6920            production: cli.production,
6921            quiet: dispatch.quiet,
6922            allow_remote_extends: cli.allow_remote_extends,
6923        },
6924    )?;
6925    let cache_dir = config.cache_dir.clone();
6926    let audit_cfg = config.audit;
6927    let production = resolve_production_modes(
6928        cli,
6929        root,
6930        output,
6931        args.production_dead_code,
6932        args.production_health,
6933        args.production_dupes,
6934    )?;
6935    let resolved_dead_code_baseline = resolve_audit_baseline_path(
6936        root,
6937        args.dead_code_baseline.as_deref(),
6938        audit_cfg.dead_code_baseline.as_deref(),
6939    );
6940    let resolved_health_baseline = resolve_audit_baseline_path(
6941        root,
6942        args.health_baseline.as_deref(),
6943        audit_cfg.health_baseline.as_deref(),
6944    );
6945    let resolved_dupes_baseline = resolve_audit_baseline_path(
6946        root,
6947        args.dupes_baseline.as_deref(),
6948        audit_cfg.dupes_baseline.as_deref(),
6949    );
6950    let coverage_inputs = resolve_coverage_inputs(
6951        args.coverage.as_deref(),
6952        args.coverage_root.as_deref(),
6953        output,
6954        || Ok(config.health),
6955    )?;
6956
6957    Ok(ResolvedAuditInputs {
6958        audit_cfg,
6959        cache_dir,
6960        production,
6961        dead_code_baseline: resolved_dead_code_baseline,
6962        health_baseline: resolved_health_baseline,
6963        dupes_baseline: resolved_dupes_baseline,
6964        coverage: coverage_inputs.coverage,
6965        coverage_root: coverage_inputs.coverage_root,
6966    })
6967}
6968
6969fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6970    !args.no_css && config.css.unwrap_or(true)
6971}
6972
6973fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6974    audit_css_enabled(config, args)
6975        && !args.no_css_deep
6976        && (args.css_deep || config.css_deep.unwrap_or(true))
6977}
6978
6979fn run_resolved_audit(
6980    dispatch: &DispatchContext<'_>,
6981    args: &AuditDispatchArgs,
6982    inputs: &ResolvedAuditInputs,
6983) -> ExitCode {
6984    let cli = dispatch.cli;
6985    audit::run_audit_with_type_aware(
6986        &audit::AuditOptions {
6987            root: dispatch.root,
6988            config_path: &cli.config,
6989            cache_dir: &inputs.cache_dir,
6990            output: dispatch.output,
6991            json_style: dispatch.json_style,
6992            no_cache: cli.no_cache,
6993            threads: dispatch.threads,
6994            quiet: dispatch.quiet,
6995            allow_remote_extends: cli.allow_remote_extends,
6996            changed_since: cli.changed_since.as_deref(),
6997            production: cli.production,
6998            production_dead_code: Some(inputs.production.dead_code),
6999            production_health: Some(inputs.production.health),
7000            production_dupes: Some(inputs.production.dupes),
7001            workspace: cli.workspace.as_deref(),
7002            changed_workspaces: cli.changed_workspaces.as_deref(),
7003            explain: cli.explain,
7004            explain_skipped: cli.explain_skipped,
7005            performance: cli.performance,
7006            group_by: cli.group_by,
7007            dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7008            health_baseline: inputs.health_baseline.as_deref(),
7009            dupes_baseline: inputs.dupes_baseline.as_deref(),
7010            health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7011            fail_on_stale_baseline: cli.fail_on_stale_baseline,
7012            max_crap: args.max_crap,
7013            coverage: inputs.coverage.as_deref(),
7014            coverage_root: inputs.coverage_root.as_deref(),
7015            gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
7016            include_entry_exports: cli.include_entry_exports,
7017            show_cascade: cli.show_cascade,
7018            fail_on_parse_error: cli.fail_on_parse_error,
7019            // Styling analytics, including deep cross-file reachability, is on
7020            // by default in `fallow audit`; both layers remain verdict-neutral
7021            // unless a user escalates a styling rule to error.
7022            css: audit_css_enabled(&inputs.audit_cfg, args),
7023            css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
7024            runtime_coverage: args.runtime_coverage.as_deref(),
7025            min_invocations_hot: args.min_invocations_hot,
7026            brief: args.brief,
7027            max_decisions: args.max_decisions,
7028            walkthrough_guide: args.walkthrough_guide,
7029            walkthrough: args.walkthrough,
7030            mark_viewed: &args.mark_viewed,
7031            show_cleared: args.show_cleared,
7032            walkthrough_file: args.walkthrough_file.as_deref(),
7033            show_deprioritized: args.show_deprioritized,
7034            scope: args.scope.clone(),
7035        },
7036        args.gate_marker.as_deref(),
7037        audit::AuditTypeAwareOptions {
7038            enabled: cli.type_aware_override(),
7039            config_default: inputs.audit_cfg.type_aware,
7040            projects: &cli.type_aware_project,
7041            require: cli.type_aware_require.map(Into::into),
7042        },
7043    )
7044}
7045
7046/// Dispatch `fallow decision-surface`: the separable apex. Reuses the audit
7047/// input resolution in brief mode (changed-code scope) with all gating /
7048/// coverage / baseline knobs defaulted, then renders ONLY the decision surface.
7049fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
7050    let args = decision_surface_audit_args(max_decisions);
7051    let inputs = match resolve_audit_inputs(dispatch, &args) {
7052        Ok(inputs) => inputs,
7053        Err(code) => return code,
7054    };
7055    // The brief owns no exit gates (see `decision_surface_audit_options`), so
7056    // an opted-in stale-baseline gate stands down here and says so for every
7057    // baseline the config supplied, like every other path that skips it.
7058    if dispatch.cli.fail_on_stale_baseline {
7059        for path in [
7060            &inputs.dead_code_baseline,
7061            &inputs.health_baseline,
7062            &inputs.dupes_baseline,
7063        ] {
7064            baseline_gate::note_stood_down(
7065                path.as_deref(),
7066                true,
7067                "decision-surface renders a brief without exit gates",
7068            );
7069        }
7070    }
7071    audit::run_decision_surface(&decision_surface_audit_options(
7072        dispatch,
7073        &inputs,
7074        max_decisions,
7075    ))
7076}
7077
7078fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
7079    AuditDispatchArgs {
7080        production_dead_code: false,
7081        production_health: false,
7082        production_dupes: false,
7083        dead_code_baseline: None,
7084        health_baseline: None,
7085        dupes_baseline: None,
7086        max_crap: None,
7087        coverage: None,
7088        coverage_root: None,
7089        no_css: true,
7090        css_deep: false,
7091        no_css_deep: false,
7092        gate: None,
7093        runtime_coverage: None,
7094        min_invocations_hot: 0,
7095        gate_marker: None,
7096        brief: true,
7097        max_decisions,
7098        walkthrough_guide: false,
7099        walkthrough_file: None,
7100        walkthrough: false,
7101        mark_viewed: Vec::new(),
7102        show_cleared: false,
7103        show_deprioritized: false,
7104        scope: None,
7105    }
7106}
7107
7108fn decision_surface_audit_options<'a>(
7109    dispatch: &'a DispatchContext<'a>,
7110    inputs: &'a ResolvedAuditInputs,
7111    max_decisions: usize,
7112) -> audit::AuditOptions<'a> {
7113    let cli = dispatch.cli;
7114    audit::AuditOptions {
7115        root: dispatch.root,
7116        config_path: &cli.config,
7117        cache_dir: &inputs.cache_dir,
7118        output: dispatch.output,
7119        json_style: dispatch.json_style,
7120        no_cache: cli.no_cache,
7121        threads: dispatch.threads,
7122        quiet: dispatch.quiet,
7123        allow_remote_extends: cli.allow_remote_extends,
7124        changed_since: cli.changed_since.as_deref(),
7125        production: cli.production,
7126        production_dead_code: Some(inputs.production.dead_code),
7127        production_health: Some(inputs.production.health),
7128        production_dupes: Some(inputs.production.dupes),
7129        workspace: cli.workspace.as_deref(),
7130        changed_workspaces: cli.changed_workspaces.as_deref(),
7131        explain: cli.explain,
7132        explain_skipped: cli.explain_skipped,
7133        performance: cli.performance,
7134        group_by: cli.group_by,
7135        dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7136        health_baseline: inputs.health_baseline.as_deref(),
7137        dupes_baseline: inputs.dupes_baseline.as_deref(),
7138        health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7139        // The decision surface renders the apex brief through
7140        // `run_decision_surface`, which owns no exit gates and prints no gate
7141        // lines, so the flag has nothing to say on this path.
7142        fail_on_stale_baseline: false,
7143        max_crap: None,
7144        coverage: None,
7145        coverage_root: None,
7146        gate: inputs.audit_cfg.gate,
7147        include_entry_exports: cli.include_entry_exports,
7148        show_cascade: cli.show_cascade,
7149        fail_on_parse_error: false,
7150        // Decision-surface (brief apex) does not render styling; keep it lean.
7151        css: false,
7152        css_deep: false,
7153        runtime_coverage: None,
7154        min_invocations_hot: 0,
7155        brief: true,
7156        max_decisions,
7157        walkthrough_guide: false,
7158        walkthrough: false,
7159        mark_viewed: &[],
7160        show_cleared: false,
7161        walkthrough_file: None,
7162        show_deprioritized: false,
7163        scope: None,
7164    }
7165}
7166
7167struct HealthDispatchArgs<'a> {
7168    max_cyclomatic: Option<u16>,
7169    max_cognitive: Option<u16>,
7170    max_crap: Option<f64>,
7171    top: Option<usize>,
7172    sort: health::SortBy,
7173    complexity: bool,
7174    complexity_breakdown: bool,
7175    file_scores: bool,
7176    coverage_gaps: bool,
7177    hotspots: bool,
7178    ownership: bool,
7179    ownership_emails: Option<fallow_config::EmailMode>,
7180    targets: bool,
7181    type_coupling: bool,
7182    css: bool,
7183    effort: Option<EffortFilter>,
7184    score: bool,
7185    min_score: Option<f64>,
7186    min_severity: Option<fallow_output::FindingSeverity>,
7187    report_only: bool,
7188    fail_on_stale_baseline: bool,
7189    fail_on_parse_error: bool,
7190    since: Option<&'a str>,
7191    min_commits: Option<u32>,
7192    save_snapshot: Option<&'a Option<String>>,
7193    trend: bool,
7194    trend_from: Option<&'a std::path::Path>,
7195    coverage: Option<&'a std::path::Path>,
7196    coverage_root: Option<&'a std::path::Path>,
7197    runtime_coverage: Option<&'a std::path::Path>,
7198    min_invocations_hot: u64,
7199    min_observation_volume: Option<u32>,
7200    low_traffic_threshold: Option<f64>,
7201    scope: Option<std::path::PathBuf>,
7202}
7203
7204type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
7205
7206/// Resolve Istanbul coverage inputs for `health`, bare combined mode, and
7207/// `audit` (#2359) with the precedence owned by
7208/// [`fallow_api::resolve_coverage_inputs`]: the CLI flag, then
7209/// `FALLOW_COVERAGE` / `FALLOW_COVERAGE_ROOT` (read here, at the CLI
7210/// boundary), then `health.coverage` / `health.coverageRoot`. Auto-detection
7211/// of `coverage/coverage-final.json` stays in the engine and only applies when
7212/// every layer is empty. `config_health` is consulted only when both a flag
7213/// and an env var are absent for at least one input, so a command that has
7214/// not loaded config yet can defer that load. A relative winning root is a
7215/// structured exit 2 before any analysis starts.
7216fn resolve_coverage_inputs(
7217    cli_coverage: Option<&std::path::Path>,
7218    cli_coverage_root: Option<&std::path::Path>,
7219    output: fallow_config::OutputFormat,
7220    config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
7221) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7222    let explicit = fallow_api::CoverageInputs {
7223        coverage: cli_coverage.map(std::path::Path::to_path_buf),
7224        coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
7225    };
7226    let env = fallow_api::CoverageInputs {
7227        coverage: path_from_env("FALLOW_COVERAGE"),
7228        coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
7229    };
7230    let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
7231        Some(config_health()?)
7232    } else {
7233        None
7234    };
7235
7236    fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
7237        .map_err(|err| emit_error(&err.to_string(), 2, output))
7238}
7239
7240/// [`resolve_coverage_inputs`] for commands that load config lazily: the
7241/// config is read only when a flag and env var are both absent.
7242fn resolve_health_coverage_inputs(
7243    dispatch: &DispatchContext<'_>,
7244    cli_coverage: Option<&std::path::Path>,
7245    cli_coverage_root: Option<&std::path::Path>,
7246) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7247    resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
7248        Ok(load_config(
7249            dispatch.root,
7250            &dispatch.cli.config,
7251            LoadConfigArgs {
7252                output: dispatch.output,
7253                no_cache: dispatch.cli.no_cache,
7254                threads: dispatch.threads,
7255                production: dispatch.cli.production,
7256                quiet: dispatch.quiet,
7257                allow_remote_extends: dispatch.cli.allow_remote_extends,
7258            },
7259        )?
7260        .health)
7261    })
7262}
7263
7264fn path_from_env(name: &str) -> Option<PathBuf> {
7265    std::env::var_os(name)
7266        .filter(|value| !value.is_empty())
7267        .map(PathBuf::from)
7268}
7269
7270fn validate_health_report_only_gate(
7271    report_only: bool,
7272    min_score: Option<f64>,
7273    min_severity: Option<fallow_output::FindingSeverity>,
7274    output: fallow_config::OutputFormat,
7275) -> Result<(), ExitCode> {
7276    if report_only && (min_score.is_some() || min_severity.is_some()) {
7277        return Err(emit_error(
7278            "--report-only cannot be combined with --min-score or --min-severity. \
7279             --report-only always exits 0; drop it to gate on score/severity, or \
7280             drop the gate flags to stay advisory.",
7281            2,
7282            output,
7283        ));
7284    }
7285
7286    Ok(())
7287}
7288
7289fn resolve_runtime_coverage_options(
7290    runtime_coverage: Option<&std::path::Path>,
7291    min_invocations_hot: u64,
7292    min_observation_volume: Option<u32>,
7293    low_traffic_threshold: Option<f64>,
7294    output: fallow_config::OutputFormat,
7295) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
7296    let Some(path) = runtime_coverage else {
7297        return Ok(None);
7298    };
7299
7300    health::coverage::prepare_options(
7301        path,
7302        min_invocations_hot,
7303        min_observation_volume,
7304        low_traffic_threshold,
7305        output,
7306    )
7307    .map(Some)
7308}
7309
7310fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
7311    let cli = dispatch.cli;
7312    let root = dispatch.root;
7313    let (output, _quiet, _fail_on_issues) =
7314        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7315    if let Err(code) = validate_health_report_only_gate(
7316        args.report_only,
7317        args.min_score,
7318        args.min_severity,
7319        output,
7320    ) {
7321        return code;
7322    }
7323    let runtime_coverage = match resolve_runtime_coverage_options(
7324        args.runtime_coverage,
7325        args.min_invocations_hot,
7326        args.min_observation_volume,
7327        args.low_traffic_threshold,
7328        output,
7329    ) {
7330        Ok(options) => options,
7331        Err(code) => return code,
7332    };
7333    let production = match resolve_production_modes(cli, root, output, false, false, false) {
7334        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
7335        Err(code) => return code,
7336    };
7337    let coverage_inputs =
7338        match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
7339            Ok(inputs) => inputs,
7340            Err(code) => return code,
7341        };
7342    let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
7343    let resolved = ResolvedHealthDispatch { run, production };
7344    // `--report-only` is a request never to fail, so the gate stands down and
7345    // says so instead of judging a baseline whose verdict cannot count.
7346    if args.report_only && cli.fail_on_baseline_growth {
7347        baseline_growth::note_stood_down(
7348            dispatch.growth_flags(),
7349            "health --report-only never fails a run",
7350        );
7351        return run_health_dispatch(dispatch, args, resolved);
7352    }
7353    let growth_targets = baseline_growth::targets(&[(
7354        cli.baseline.as_deref(),
7355        fallow_engine::baseline::BaselineKind::Health,
7356    )]);
7357    baseline_growth::run_gated(
7358        &dispatch.growth_ctx(baseline_growth::GrowthOwner::Health),
7359        &growth_targets,
7360        || run_health_dispatch(dispatch, args, resolved),
7361    )
7362}
7363
7364fn derive_health_dispatch_run<'a>(
7365    args: &'a HealthDispatchArgs<'a>,
7366    output: fallow_config::OutputFormat,
7367    coverage_inputs: &'a ResolvedHealthCoverageInputs,
7368    runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
7369) -> fallow_engine::health::HealthRunOptions<'a> {
7370    let mut run = fallow_engine::health::derive_health_run_options(
7371        fallow_engine::health::HealthRunOptionsInput {
7372            output,
7373            thresholds: health_threshold_overrides(args),
7374            top: args.top,
7375            sort: args.sort.clone().into(),
7376            complexity: args.complexity,
7377            file_scores: args.file_scores,
7378            coverage_gaps: args.coverage_gaps,
7379            hotspots: args.hotspots,
7380            ownership: args.ownership,
7381            ownership_emails: args.ownership_emails,
7382            targets: args.targets,
7383            css: args.css,
7384            effort: args.effort.map(EffortFilter::to_estimate),
7385            score: args.score,
7386            gates: health_gate_options(args),
7387            snapshot_requested: args.save_snapshot.is_some(),
7388            trend: args.trend,
7389            since: args.since,
7390            min_commits: args.min_commits,
7391            coverage_inputs: health_coverage_inputs(coverage_inputs),
7392            runtime_coverage,
7393        },
7394    );
7395    if args.type_coupling && !run.sections.any_section {
7396        run.sections = fallow_engine::health::DerivedHealthSections {
7397            any_section: true,
7398            complexity: false,
7399            file_scores: false,
7400            coverage_gaps: false,
7401            hotspots: false,
7402            targets: false,
7403            css: false,
7404            score: false,
7405            force_full: false,
7406            score_only_output: false,
7407        };
7408    }
7409    run
7410}
7411
7412fn health_threshold_overrides(
7413    args: &HealthDispatchArgs<'_>,
7414) -> fallow_engine::health::HealthThresholdOverrides {
7415    fallow_engine::health::HealthThresholdOverrides {
7416        max_cyclomatic: args.max_cyclomatic,
7417        max_cognitive: args.max_cognitive,
7418        max_crap: args.max_crap,
7419    }
7420}
7421
7422fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
7423    fallow_engine::health::HealthGateOptions {
7424        min_score: args.min_score,
7425        min_severity: args.min_severity,
7426        report_only: args.report_only,
7427        fail_on_stale_baseline: args.fail_on_stale_baseline,
7428        fail_on_parse_error: args.fail_on_parse_error,
7429        fail_on_issues: false,
7430    }
7431}
7432
7433fn health_coverage_inputs(
7434    coverage_inputs: &ResolvedHealthCoverageInputs,
7435) -> fallow_engine::health::HealthCoverageInputs<'_> {
7436    fallow_engine::health::HealthCoverageInputs {
7437        coverage: coverage_inputs.coverage.as_deref(),
7438        coverage_root: coverage_inputs.coverage_root.as_deref(),
7439        coverage_relocated: false,
7440    }
7441}
7442
7443/// Resolved inputs threaded from `dispatch_health` into the `HealthOptions`
7444/// builder. Owns the normalized engine run contract and resolved production
7445/// mode.
7446struct ResolvedHealthDispatch<'a> {
7447    run: fallow_engine::health::HealthRunOptions<'a>,
7448    production: bool,
7449}
7450
7451/// Build `HealthOptions` from the parsed args plus the resolved dispatch inputs,
7452/// then run the health analysis.
7453fn run_health_dispatch(
7454    dispatch: &DispatchContext<'_>,
7455    args: &HealthDispatchArgs<'_>,
7456    resolved: ResolvedHealthDispatch<'_>,
7457) -> ExitCode {
7458    let cli = dispatch.cli;
7459    let (output, quiet, fail_on_issues) =
7460        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7461    let mut run = resolved.run;
7462    run.gates.fail_on_issues = fail_on_issues;
7463    let sections = run.sections;
7464    let production = resolved.production;
7465    health::run_health(
7466        &HealthOptions {
7467            root: dispatch.root,
7468            config_path: &cli.config,
7469            output,
7470            no_cache: cli.no_cache,
7471            threads: dispatch.threads,
7472            quiet,
7473            thresholds: run.thresholds,
7474            top: run.top,
7475            sort: run.sort,
7476            production,
7477            production_override: Some(production),
7478            allow_remote_extends: cli.allow_remote_extends,
7479            changed_since: cli.changed_since.as_deref(),
7480            diff_index: None,
7481            use_shared_diff_index: true,
7482            workspace: cli.workspace.as_deref(),
7483            changed_workspaces: cli.changed_workspaces.as_deref(),
7484            baseline: cli.baseline.as_deref(),
7485            save_baseline: cli.save_baseline.as_deref(),
7486            baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7487            baseline_mode_explicit: cli.baseline_mode.is_some(),
7488            complexity: sections.complexity,
7489            file_scores: sections.file_scores,
7490            coverage_gaps: sections.coverage_gaps,
7491            config_activates_coverage_gaps: !sections.any_section,
7492            hotspots: sections.hotspots,
7493            ownership: run.ownership,
7494            ownership_emails: run.ownership_emails,
7495            targets: sections.targets,
7496            css: sections.css,
7497            css_deep: false,
7498            force_full: sections.force_full,
7499            score_only_output: sections.score_only_output,
7500            enforce_coverage_gap_gate: true,
7501            effort: run.effort,
7502            score: sections.score,
7503            gates: run.gates,
7504            since: run.since,
7505            min_commits: run.min_commits,
7506            explain: cli.explain,
7507            summary: cli.summary,
7508            save_snapshot: args
7509                .save_snapshot
7510                .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
7511            trend: args.trend,
7512            coverage_inputs: run.coverage_inputs,
7513            performance: cli.performance,
7514            runtime_coverage: run.runtime_coverage,
7515            churn_file: cli.churn_file.as_deref(),
7516            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
7517            complexity_breakdown: args.complexity_breakdown,
7518            group_by: cli.group_by.map(Into::into),
7519            group_filter: cli.group.as_deref(),
7520            trend_from: args.trend_from,
7521            scope: args.scope.clone(),
7522        },
7523        dispatch.json_style,
7524        &health::TypeAwareHealthOptions {
7525            enabled: cli.type_aware_override(),
7526            requested: args.type_coupling,
7527            unfiltered: health_type_coupling_is_default_section(args),
7528            projects: &cli.type_aware_project,
7529            require: cli.type_aware_require.map(Into::into),
7530        },
7531    )
7532}
7533
7534fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
7535    !args.complexity
7536        && !args.file_scores
7537        && !args.coverage_gaps
7538        && !args.hotspots
7539        && !args.ownership
7540        && !args.targets
7541        && !args.css
7542        && !args.score
7543        && args.min_score.is_none()
7544        && args.min_severity.is_none()
7545        && args.runtime_coverage.is_none()
7546}
7547
7548#[cfg(test)]
7549mod tests {
7550    use super::*;
7551
7552    /// Validates that the CLI definition has no flag name collisions, missing
7553    /// fields, or other structural errors. Catches issues like a global alias
7554    /// `--base` colliding with a subcommand's `--base` flag.
7555    #[test]
7556    fn cli_definition_has_no_flag_collisions() {
7557        use clap::CommandFactory;
7558        Cli::command().debug_assert();
7559    }
7560
7561    #[test]
7562    fn impact_statusline_subcommand_parses() {
7563        use clap::Parser;
7564
7565        let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7566        assert!(matches!(
7567            cli.command,
7568            Some(Command::Impact {
7569                subcommand: Some(ImpactCli::Statusline),
7570                ..
7571            })
7572        ));
7573    }
7574
7575    #[test]
7576    fn impact_statusline_bypasses_command_epilogue() {
7577        use clap::Parser;
7578
7579        let statusline =
7580            Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7581        assert!(is_impact_statusline(&statusline));
7582
7583        let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
7584        assert!(!is_impact_statusline(&status));
7585
7586        let all_statusline =
7587            Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
7588        assert!(!is_impact_statusline(&all_statusline));
7589    }
7590
7591    #[test]
7592    fn regression_baseline_help_explains_the_default_destination() {
7593        use clap::CommandFactory;
7594        let help = Cli::command().render_long_help().to_string();
7595
7596        assert!(help.contains("Omit PATH to update regression.baseline"));
7597        assert!(help.contains("discovered fallow config"));
7598        assert!(help.contains("create .fallowrc.json when none exists"));
7599    }
7600
7601    /// The root `--help` cheat sheet is a static const that cannot call the
7602    /// shared renderer, so this test is the only guard that it stays in sync
7603    /// with `TASK_MATRIX`. Every row's command string must appear verbatim.
7604    #[test]
7605    fn after_help_lists_every_task_matrix_command() {
7606        for row in crate::task_matrix::TASK_MATRIX {
7607            assert!(
7608                TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
7609                "root --help cheat sheet is missing task-matrix command '{}'; \
7610                 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
7611                row.command
7612            );
7613        }
7614    }
7615
7616    /// The curated command groups replace clap's auto-generated subcommand
7617    /// list, so a new subcommand stays invisible in `fallow --help` unless it
7618    /// is added here. Substring matching is not enough (e.g. `--trace`
7619    /// contains `trace`), so each name must lead a group line. The full
7620    /// `--help` surface is the one that must stay complete; `-h` is the
7621    /// curated progressive subset.
7622    #[test]
7623    fn after_help_lists_every_visible_subcommand() {
7624        use clap::CommandFactory;
7625
7626        for sub in Cli::command().get_subcommands() {
7627            if sub.is_hide_set() {
7628                continue;
7629            }
7630            let name = sub.get_name();
7631            let listed = TOP_LEVEL_AFTER_LONG_HELP
7632                .lines()
7633                .any(|line| line.split_whitespace().next() == Some(name));
7634            assert!(
7635                listed,
7636                "root --help command list is missing subcommand '{name}'; \
7637                 add it to a top_level_*_command_groups! section"
7638            );
7639        }
7640    }
7641
7642    /// A new visible subcommand must say whether it writes an analysis report.
7643    /// A row in `COMMAND_ENVELOPES` binds it to the schema, `report --from`,
7644    /// MCP and drift tests; the other list records why it has no row.
7645    #[test]
7646    fn every_visible_subcommand_is_classified_for_its_machine_contract() {
7647        use clap::CommandFactory;
7648        use fallow_types::command_surfaces::{
7649            COMMAND_ENVELOPES, COMMANDS_WITHOUT_ANALYSIS_ENVELOPE,
7650        };
7651
7652        let visible: Vec<String> = Cli::command()
7653            .get_subcommands()
7654            .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
7655            .map(|sub| sub.get_name().to_string())
7656            .collect();
7657        for name in &visible {
7658            let rows = COMMAND_ENVELOPES
7659                .iter()
7660                .filter(|row| row.command == name)
7661                .count();
7662            let others = COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7663                .iter()
7664                .filter(|(command, _)| command == name)
7665                .count();
7666            assert_eq!(
7667                rows + others,
7668                1,
7669                "subcommand `{name}` must be in exactly one of COMMAND_ENVELOPES and \
7670                 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE (crates/types/src/command_surfaces.rs)"
7671            );
7672        }
7673        let listed = COMMAND_ENVELOPES.iter().map(|row| row.command).chain(
7674            COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7675                .iter()
7676                .map(|(command, _)| *command),
7677        );
7678        for command in listed {
7679            assert!(
7680                visible.iter().any(|name| name == command),
7681                "`{command}` is listed in command_surfaces.rs but is not a visible subcommand"
7682            );
7683        }
7684    }
7685
7686    /// `fallow architecture` selects the same issue types as the deprecated
7687    /// `dead-code` structure flags, so the two produce the same report.
7688    #[test]
7689    fn architecture_flags_map_to_the_dead_code_structure_filters() {
7690        use clap::Parser;
7691
7692        let structure = |argv: &[&str]| {
7693            let cli = Cli::try_parse_from(argv).expect("argv parses");
7694            check_issue_filters(&cli.command.expect("subcommand"))
7695        };
7696        let architecture =
7697            |cycles, boundaries, policy| architecture_issue_filters(cycles, boundaries, policy);
7698        assert_eq!(
7699            architecture(false, false, false),
7700            structure(&[
7701                "fallow",
7702                "dead-code",
7703                "--circular-deps",
7704                "--re-export-cycles",
7705                "--package-cycles",
7706                "--boundary-violations",
7707                "--policy-violations",
7708            ])
7709        );
7710        assert_eq!(
7711            architecture(true, false, false),
7712            structure(&[
7713                "fallow",
7714                "dead-code",
7715                "--circular-deps",
7716                "--re-export-cycles",
7717                "--package-cycles",
7718            ])
7719        );
7720        assert_eq!(
7721            architecture(false, true, false),
7722            structure(&["fallow", "dead-code", "--boundary-violations"])
7723        );
7724        assert_eq!(
7725            architecture(false, false, true),
7726            structure(&["fallow", "dead-code", "--policy-violations"])
7727        );
7728        assert!(
7729            Cli::try_parse_from([
7730                "fallow",
7731                "architecture",
7732                "--cycles",
7733                "--boundaries",
7734                "--policy"
7735            ])
7736            .is_ok()
7737        );
7738    }
7739
7740    /// `-h` is the progressive entry point: it must stay scannable (ecosystem
7741    /// norm is 40-80 lines) while leading with the task cheat sheet and
7742    /// closing with the pointer to the complete `--help` surface.
7743    #[test]
7744    fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
7745        use clap::CommandFactory;
7746
7747        let help = Cli::command().render_help().to_string();
7748        let lines = help.lines().count();
7749        assert!(
7750            lines < 90,
7751            "root -h grew to {lines} lines; keep the short surface under 90 \
7752             (curate hide_short_help and the short after-help instead)"
7753        );
7754        assert!(help.contains("When the agent is about to..."));
7755        assert!(help.contains("Run fallow --help for the complete command list."));
7756    }
7757
7758    /// The high-value and coarse admin commands each get a distinct telemetry
7759    /// workflow instead of the `Unknown` catch-all, so command families stay
7760    /// answerable without uploading raw command lines.
7761    #[test]
7762    fn high_value_commands_route_to_distinct_workflows() {
7763        use clap::Parser;
7764        use fallow_config::OutputFormat;
7765
7766        let distinct = [
7767            (vec!["fallow", "impact"], telemetry::Workflow::Impact),
7768            (vec!["fallow", "security"], telemetry::Workflow::Security),
7769            (vec!["fallow", "fix"], telemetry::Workflow::Fix),
7770            (
7771                vec!["fallow", "explain", "unused-exports"],
7772                telemetry::Workflow::Explain,
7773            ),
7774            (
7775                vec!["fallow", "watch"],
7776                telemetry::Workflow::CodeQualityReview,
7777            ),
7778            (
7779                vec!["fallow", "list"],
7780                telemetry::Workflow::ProjectInventory,
7781            ),
7782            (
7783                vec!["fallow", "workspaces"],
7784                telemetry::Workflow::ProjectInventory,
7785            ),
7786            (
7787                vec!["fallow", "schema"],
7788                telemetry::Workflow::ProjectInventory,
7789            ),
7790            (vec!["fallow", "init"], telemetry::Workflow::Setup),
7791            (
7792                vec!["fallow", "hooks", "install", "--target", "git"],
7793                telemetry::Workflow::Setup,
7794            ),
7795            (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
7796            (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
7797            (
7798                vec!["fallow", "rule-pack-schema"],
7799                telemetry::Workflow::Setup,
7800            ),
7801            (vec!["fallow", "config"], telemetry::Workflow::Setup),
7802            (
7803                vec!["fallow", "ci-template", "gitlab"],
7804                telemetry::Workflow::Setup,
7805            ),
7806            (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
7807            (
7808                vec!["fallow", "telemetry", "status"],
7809                telemetry::Workflow::Setup,
7810            ),
7811            (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
7812            (
7813                vec!["fallow", "audit-cache", "remove", "--root", "."],
7814                telemetry::Workflow::Setup,
7815            ),
7816            (
7817                vec!["fallow", "license", "status"],
7818                telemetry::Workflow::License,
7819            ),
7820            (
7821                vec!["fallow", "architecture", "--cycles"],
7822                telemetry::Workflow::Architecture,
7823            ),
7824        ];
7825        for (argv, expected) in distinct {
7826            let cli = Cli::try_parse_from(&argv).expect("argv parses");
7827            assert_eq!(
7828                telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
7829                expected,
7830                "{argv:?} should map to {expected:?}"
7831            );
7832        }
7833    }
7834
7835    /// `-v`, `-V`, and `--version` must all trigger clap's Version action so
7836    /// the version prints regardless of which spelling the user reaches for
7837    /// (issue #916). clap surfaces a Version action from `try_get_matches_from`
7838    /// as the `DisplayVersion` error kind.
7839    #[test]
7840    fn version_flag_accepts_lower_v_upper_v_and_long() {
7841        use clap::CommandFactory;
7842        for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
7843            let err = Cli::command()
7844                .try_get_matches_from(argv)
7845                .expect_err("version flag should short-circuit parsing");
7846            assert_eq!(
7847                err.kind(),
7848                clap::error::ErrorKind::DisplayVersion,
7849                "{argv:?} should trigger the Version action"
7850            );
7851        }
7852    }
7853
7854    /// Guard against deferred-work wording leaking into clap-rendered help.
7855    /// `stub`, `placeholder`, and `not yet` framings tell users the feature
7856    /// is broken or pending; they belong in tracked issues, not in `--help`.
7857    /// Walk every (sub)command and assert each rendered long-help is clean.
7858    #[test]
7859    fn cli_help_text_contains_no_implementation_status_wording() {
7860        use clap::CommandFactory;
7861        let mut root = Cli::command();
7862        let mut violations: Vec<(String, String)> = Vec::new();
7863        visit_help(&mut root, "fallow", &mut violations);
7864        assert!(
7865            violations.is_empty(),
7866            "found implementation-status wording in --help output:\n{}",
7867            violations
7868                .iter()
7869                .map(|(cmd, line)| format!("  {cmd}: {line}"))
7870                .collect::<Vec<_>>()
7871                .join("\n")
7872        );
7873    }
7874
7875    #[test]
7876    fn dependency_override_help_is_package_manager_neutral() {
7877        use clap::CommandFactory;
7878        let help = Cli::command()
7879            .find_subcommand_mut("dead-code")
7880            .expect("dead-code command")
7881            .render_long_help()
7882            .to_string();
7883
7884        assert!(help.contains("Only report unused package-manager dependency overrides"));
7885        assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
7886        assert!(!help.contains("unused pnpm dependency overrides"));
7887        assert!(!help.contains("misconfigured pnpm dependency overrides"));
7888    }
7889
7890    #[test]
7891    fn top_level_help_groups_commands_by_workflow() {
7892        use clap::CommandFactory;
7893        let help = Cli::command().render_long_help().to_string();
7894        let expected_order = [
7895            "Analysis:",
7896            "  dead-code",
7897            "  dupes",
7898            "  health",
7899            "  flags",
7900            "  security",
7901            "  audit",
7902            "Workflow:",
7903            "  watch",
7904            "  fix",
7905            "Project inspection:",
7906            "  list",
7907            "  workspaces",
7908            "  explain",
7909            "  impact",
7910            "  viz",
7911            "Setup and configuration:",
7912            "  init",
7913            "  recommend",
7914            "  migrate",
7915            "  config",
7916            "  config-schema",
7917            "  plugin-schema",
7918            "  plugin-check",
7919            "  rule-pack-schema",
7920            "Automation and CI:",
7921            "  ci",
7922            "  ci-template",
7923            "  hooks",
7924            "  setup-hooks",
7925            "Runtime coverage:",
7926            "  coverage",
7927            "  license",
7928            "Reference:",
7929            "  schema",
7930            "  help",
7931            "Options:",
7932        ];
7933        let mut cursor = 0;
7934        for needle in expected_order {
7935            let Some(offset) = help[cursor..].find(needle) else {
7936                panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7937            };
7938            cursor += offset + needle.len();
7939        }
7940    }
7941
7942    /// Every global `--dupes-*` override flag only applies to a duplication
7943    /// run. `security`, `doctor` and `similar-code` must reject each one, and
7944    /// the security help must hide each one.
7945    #[test]
7946    fn every_global_dupes_override_is_rejected_where_dupes_does_not_run() {
7947        let longs: Vec<String> = <Cli as clap::CommandFactory>::command()
7948            .get_arguments()
7949            .filter_map(|arg| arg.get_long())
7950            .filter(|long| long.starts_with("dupes-") && *long != "dupes-baseline")
7951            .map(str::to_owned)
7952            .collect();
7953        assert!(
7954            longs.len() > 1,
7955            "expected global --dupes-* flags, got {longs:?}"
7956        );
7957        let security_help = render_security_help(SecurityHelpTarget::Parent);
7958
7959        for long in &longs {
7960            let flag = format!("--{long}");
7961            let takes_value = <Cli as clap::CommandFactory>::command()
7962                .get_arguments()
7963                .find(|arg| arg.get_long() == Some(long.as_str()))
7964                .is_some_and(|arg| arg.get_action().takes_values());
7965            let value = if long == "dupes-mode" { "weak" } else { "3" };
7966            let argv_for = |command: &[&str]| {
7967                let mut argv = vec!["fallow"];
7968                argv.extend_from_slice(command);
7969                argv.push(flag.as_str());
7970                if takes_value {
7971                    argv.push(value);
7972                }
7973                Cli::try_parse_from(argv).expect("global dupes flag parses")
7974            };
7975
7976            let security = argv_for(&["security"]);
7977            assert_eq!(unsupported_security_global(&security), Some(flag.as_str()));
7978            let doctor = argv_for(&["doctor"]);
7979            assert_eq!(unsupported_doctor_option(&doctor), Some(flag.as_str()));
7980            let similar_code = argv_for(&["similar-code", "status"]);
7981            assert_eq!(
7982                similar_code_help::unsupported_similar_code_option(&similar_code),
7983                Some(flag.as_str())
7984            );
7985            assert!(
7986                !help_contains_long_flag(&security_help, long),
7987                "security help must hide unsupported {flag}:\n{security_help}"
7988            );
7989        }
7990    }
7991
7992    #[test]
7993    fn security_help_hides_globals_rejected_by_security_validator() {
7994        let help = render_security_help(SecurityHelpTarget::Parent);
7995
7996        for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7997            assert!(
7998                !help_contains_long_flag(&help, long),
7999                "security help must hide unsupported --{long}:\n{help}"
8000            );
8001        }
8002
8003        for long in [
8004            "root",
8005            "config",
8006            "format",
8007            "quiet",
8008            "no-cache",
8009            "threads",
8010            "changed-since",
8011            "diff-file",
8012            "diff-stdin",
8013            "workspace",
8014            "changed-workspaces",
8015            "ci",
8016            "fail-on-issues",
8017            "sarif-file",
8018            "summary",
8019            "output-file",
8020            "max-file-size",
8021            "explain",
8022            "surface",
8023        ] {
8024            assert!(
8025                help_contains_long_flag(&help, long),
8026                "security help must keep supported --{long}:\n{help}"
8027            );
8028        }
8029    }
8030
8031    #[test]
8032    fn security_help_detection_covers_subcommand_and_help_alias_forms() {
8033        assert_eq!(
8034            security_help_target(["security", "--help"]),
8035            Some(SecurityHelpTarget::Parent)
8036        );
8037        assert_eq!(
8038            security_help_target(["security", "-h"]),
8039            Some(SecurityHelpTarget::Parent)
8040        );
8041        assert_eq!(
8042            security_help_target(["--format", "json", "security", "--help"]),
8043            Some(SecurityHelpTarget::Parent)
8044        );
8045        assert_eq!(
8046            security_help_target(["help", "security"]),
8047            Some(SecurityHelpTarget::Parent)
8048        );
8049        assert_eq!(
8050            security_help_target(["security", "survivors", "--help"]),
8051            Some(SecurityHelpTarget::Survivors)
8052        );
8053        assert_eq!(
8054            security_help_target(["security", "survivors", "-h"]),
8055            Some(SecurityHelpTarget::Survivors)
8056        );
8057        assert_eq!(
8058            security_help_target(["help", "security", "survivors"]),
8059            Some(SecurityHelpTarget::Survivors)
8060        );
8061        assert_eq!(
8062            security_help_target(["security", "blind-spots", "--help"]),
8063            Some(SecurityHelpTarget::BlindSpots)
8064        );
8065        assert_eq!(
8066            security_help_target(["help", "security", "blind-spots"]),
8067            Some(SecurityHelpTarget::BlindSpots)
8068        );
8069        assert_eq!(security_help_target(["health", "--help"]), None);
8070        assert_eq!(security_help_target(["help", "health"]), None);
8071    }
8072
8073    #[test]
8074    fn security_unsupported_global_validator_matches_hidden_help_contract() {
8075        for (argv, expected) in [
8076            (vec!["fallow", "security", "--performance"], "--performance"),
8077            (
8078                vec!["fallow", "security", "--no-package-baselines"],
8079                "--no-package-baselines",
8080            ),
8081            (
8082                vec!["fallow", "security", "--baseline", "base.json"],
8083                "--baseline",
8084            ),
8085            (
8086                vec!["fallow", "security", "--fail-on-stale-baseline"],
8087                "--fail-on-stale-baseline",
8088            ),
8089            (
8090                vec!["fallow", "security", "--dupes-mode", "weak"],
8091                "--dupes-mode",
8092            ),
8093            (vec!["fallow", "security", "--dupes-near"], "--dupes-near"),
8094        ] {
8095            let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
8096            assert_eq!(unsupported_security_global(&cli), Some(expected));
8097        }
8098
8099        let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
8100            .expect("security --explain parses");
8101        assert_eq!(unsupported_security_global(&explain), None);
8102    }
8103
8104    #[test]
8105    fn programmatic_common_options_track_analysis_affecting_cli_globals() {
8106        use clap::CommandFactory;
8107
8108        let cli_flags: std::collections::BTreeSet<String> = Cli::command()
8109            .get_arguments()
8110            .filter(|arg| arg.is_global_set())
8111            .filter_map(|arg| arg.get_long().map(str::to_owned))
8112            .filter(|name| {
8113                matches!(
8114                    name.as_str(),
8115                    "root"
8116                        | "config"
8117                        | "allow-remote-extends"
8118                        | "no-cache"
8119                        | "threads"
8120                        | "changed-since"
8121                        | "diff-file"
8122                        | "production"
8123                        | "workspace"
8124                        | "changed-workspaces"
8125                        | "explain"
8126                )
8127            })
8128            .collect();
8129        let programmatic_flags: std::collections::BTreeSet<String> =
8130            fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
8131                .iter()
8132                .map(|flag| (*flag).to_owned())
8133                .collect();
8134
8135        assert_eq!(programmatic_flags, cli_flags);
8136    }
8137
8138    #[test]
8139    fn dead_code_registry_filter_flags_are_exposed_by_clap() {
8140        use clap::CommandFactory;
8141
8142        let cli = Cli::command();
8143        let dead_code = cli
8144            .get_subcommands()
8145            .find(|command| command.get_name() == "dead-code")
8146            .expect("dead-code subcommand is registered");
8147        let cli_flags: std::collections::BTreeSet<String> = dead_code
8148            .get_arguments()
8149            .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
8150            .collect();
8151
8152        for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
8153            assert!(
8154                cli_flags.contains(*flag),
8155                "registry filter flag {flag} is missing from dead-code clap args"
8156            );
8157        }
8158    }
8159
8160    fn help_contains_long_flag(help: &str, long: &str) -> bool {
8161        let flag = format!("--{long}");
8162        help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
8163            .any(|token| token == flag)
8164    }
8165
8166    fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
8167        let help = cmd.render_long_help().to_string();
8168        for line in scan_forbidden(&help) {
8169            violations.push((path.to_owned(), line));
8170        }
8171        let names: Vec<String> = cmd
8172            .get_subcommands()
8173            .map(|sub| sub.get_name().to_owned())
8174            .collect();
8175        for name in names {
8176            if name == "help" {
8177                continue;
8178            }
8179            if let Some(sub) = cmd.find_subcommand_mut(&name) {
8180                let sub_path = format!("{path} {name}");
8181                visit_help(sub, &sub_path, violations);
8182            }
8183        }
8184    }
8185
8186    fn scan_forbidden(s: &str) -> Vec<String> {
8187        let lower = s.to_ascii_lowercase();
8188        let mut out = Vec::new();
8189        for word in ["stub", "placeholder"] {
8190            if let Some(idx) = find_whole_word(&lower, word) {
8191                out.push(extract_line(s, idx));
8192            }
8193        }
8194        if let Some(idx) = lower.find("not yet") {
8195            out.push(extract_line(s, idx));
8196        }
8197        out
8198    }
8199
8200    fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
8201        let bytes = haystack.as_bytes();
8202        let mut start = 0;
8203        while let Some(rel) = haystack[start..].find(word) {
8204            let abs = start + rel;
8205            let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
8206            let after_idx = abs + word.len();
8207            let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
8208            if before_ok && after_ok {
8209                return Some(abs);
8210            }
8211            start = abs + word.len();
8212        }
8213        None
8214    }
8215
8216    fn extract_line(s: &str, byte_idx: usize) -> String {
8217        let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
8218        let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
8219        s[line_start..line_end].trim().to_owned()
8220    }
8221
8222    #[test]
8223    fn emit_error_returns_given_exit_code() {
8224        let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
8225        assert_eq!(code, ExitCode::from(2));
8226    }
8227
8228    fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
8229        TelemetryRun {
8230            workflow: telemetry::Workflow::Health,
8231            output: fallow_config::OutputFormat::Json,
8232            quiet: true,
8233            start: std::time::Instant::now(),
8234            context: telemetry::WorkflowContext {
8235                run_scope: telemetry::RunScope::FullProject,
8236                config_shape: telemetry::ConfigShape::Default,
8237                output_destination: telemetry::OutputDestination::Stdout,
8238                analysis_mode: mode,
8239            },
8240        }
8241    }
8242
8243    #[test]
8244    fn fallback_failure_reason_skips_success_and_findings() {
8245        let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8246
8247        assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
8248        assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
8249    }
8250
8251    #[test]
8252    fn fallback_failure_reason_classifies_network_auth_and_analysis() {
8253        let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8254        let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
8255
8256        assert_eq!(
8257            fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
8258            Some(telemetry::FailureReason::Network),
8259        );
8260        assert_eq!(
8261            fallback_failure_reason_for(&static_run, ExitCode::from(12)),
8262            Some(telemetry::FailureReason::Auth),
8263        );
8264        assert_eq!(
8265            fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
8266            Some(telemetry::FailureReason::Auth),
8267        );
8268        assert_eq!(
8269            fallback_failure_reason_for(&static_run, ExitCode::from(2)),
8270            Some(telemetry::FailureReason::Analysis),
8271        );
8272    }
8273
8274    #[test]
8275    fn bare_coverage_flags_parse_without_subcommand() {
8276        let cli = Cli::try_parse_from([
8277            "fallow",
8278            "--coverage",
8279            "coverage/coverage-final.json",
8280            "--coverage-root",
8281            "/ci/workspace",
8282        ])
8283        .expect("bare combined coverage flags should parse");
8284        assert!(cli.command.is_none());
8285        assert_eq!(
8286            cli.coverage.as_deref(),
8287            Some(std::path::Path::new("coverage/coverage-final.json"))
8288        );
8289        assert_eq!(
8290            cli.coverage_root.as_deref(),
8291            Some(std::path::Path::new("/ci/workspace"))
8292        );
8293    }
8294
8295    #[test]
8296    fn bare_coverage_before_subcommand_is_detectable() {
8297        let cli = Cli::try_parse_from([
8298            "fallow",
8299            "--coverage",
8300            "coverage/coverage-final.json",
8301            "dead-code",
8302        ])
8303        .expect("clap should parse pre-subcommand bare coverage for custom rejection");
8304        assert!(cli.command.is_some());
8305        assert!(cli_has_bare_coverage_input(&cli));
8306        let message = bare_coverage_subcommand_error_message();
8307        assert!(message.contains("bare combined-mode flags"));
8308        assert!(message.contains("fallow health --coverage <coverage-final.json>"));
8309    }
8310
8311    #[test]
8312    fn bare_combined_baseline_before_subcommand_is_detectable() {
8313        for flag in ["--dupes-baseline", "--health-baseline"] {
8314            let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
8315                .expect("clap should parse a pre-subcommand combined baseline");
8316            assert!(cli.command.is_some());
8317            assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
8318            let message = bare_combined_baseline_subcommand_error_message(flag);
8319            assert!(message.contains(flag));
8320            assert!(message.contains("omit the subcommand"));
8321        }
8322        let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
8323            .expect("bare combined baseline should parse");
8324        assert!(bare.command.is_none());
8325    }
8326
8327    #[test]
8328    fn subcommand_coverage_flag_keeps_regular_clap_error() {
8329        let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
8330            panic!("dead-code --coverage should fail to parse");
8331        };
8332        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
8333    }
8334
8335    #[test]
8336    fn type_aware_flags_parse_for_semantic_analysis() {
8337        let cli = Cli::try_parse_from([
8338            "fallow",
8339            "dead-code",
8340            "--unused-class-members",
8341            "--type-aware",
8342            "--type-aware-project",
8343            "tsconfig.json",
8344            "--type-aware-project",
8345            "packages/web/tsconfig.json",
8346        ])
8347        .expect("type-aware flag should parse");
8348        assert!(cli.type_aware);
8349        assert_eq!(
8350            cli.type_aware_project,
8351            [
8352                PathBuf::from("tsconfig.json"),
8353                PathBuf::from("packages/web/tsconfig.json")
8354            ]
8355        );
8356        let Some(Command::Check {
8357            unused_class_members,
8358            ..
8359        }) = cli.command
8360        else {
8361            panic!("dead-code should parse as the check command");
8362        };
8363        assert!(unused_class_members);
8364    }
8365
8366    #[test]
8367    fn no_type_aware_conflicts_with_type_aware() {
8368        let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
8369        else {
8370            panic!("--no-type-aware must conflict with --type-aware");
8371        };
8372        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
8373    }
8374
8375    #[test]
8376    fn no_type_aware_forces_semantic_analysis_off() {
8377        let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
8378            .expect("--no-type-aware should parse on audit");
8379        assert_eq!(cli.type_aware_override(), Some(false));
8380
8381        let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
8382            .expect("--type-aware should parse");
8383        assert_eq!(cli.type_aware_override(), Some(true));
8384
8385        let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
8386        assert_eq!(cli.type_aware_override(), None);
8387    }
8388
8389    #[test]
8390    fn type_aware_status_output_hides_host_paths() {
8391        let root = Path::new("/private/work/project");
8392        let output = type_aware_status_output(
8393            root,
8394            fallow_api::TypeAwareStatus {
8395                available: false,
8396                discovery_source: Some("environment-override"),
8397                companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
8398                package_version: None,
8399                protocol_version: 7,
8400                backend_family: None,
8401                backend_version: None,
8402                remediation: Some(
8403                    "failed to launch /private/tools/fallow-type-aware from /private/work/project"
8404                        .to_string(),
8405                ),
8406            },
8407        );
8408
8409        assert_eq!(
8410            output.schema_version.0,
8411            fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
8412        );
8413        assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
8414        let remediation = output.remediation.expect("remediation");
8415        assert!(!remediation.contains("/private/"));
8416        assert!(remediation.contains("fallow-type-aware"));
8417    }
8418
8419    #[test]
8420    fn format_parsing_covers_all_variants() {
8421        assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
8422        assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
8423        assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
8424        assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
8425        assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
8426        assert!(matches!(
8427            parse_format_arg("markdown"),
8428            Some(Format::Markdown)
8429        ));
8430        assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
8431        assert!(matches!(
8432            parse_format_arg("codeclimate"),
8433            Some(Format::CodeClimate)
8434        ));
8435        assert!(matches!(
8436            parse_format_arg("gitlab-codequality"),
8437            Some(Format::CodeClimate)
8438        ));
8439        assert!(matches!(
8440            parse_format_arg("gitlab-code-quality"),
8441            Some(Format::CodeClimate)
8442        ));
8443        assert!(matches!(
8444            parse_format_arg("pr-comment-github"),
8445            Some(Format::PrCommentGithub)
8446        ));
8447        assert!(matches!(
8448            parse_format_arg("pr-comment-gitlab"),
8449            Some(Format::PrCommentGitlab)
8450        ));
8451        assert!(matches!(
8452            parse_format_arg("review-github"),
8453            Some(Format::ReviewGithub)
8454        ));
8455        assert!(matches!(
8456            parse_format_arg("review-gitlab"),
8457            Some(Format::ReviewGitlab)
8458        ));
8459        assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
8460        assert!(parse_format_arg("xml").is_none());
8461        assert!(parse_format_arg("").is_none());
8462    }
8463
8464    #[test]
8465    fn quiet_parsing_logic() {
8466        let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
8467        assert!(parse("1"));
8468        assert!(parse("true"));
8469        assert!(parse("TRUE"));
8470        assert!(parse("True"));
8471        assert!(!parse("0"));
8472        assert!(!parse("false"));
8473        assert!(!parse("yes"));
8474    }
8475
8476    #[test]
8477    fn tracing_filter_defaults_to_warn_without_env() {
8478        assert_eq!(build_tracing_filter(None).to_string(), "warn");
8479    }
8480
8481    #[test]
8482    fn tracing_filter_respects_explicit_env_directives() {
8483        assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
8484    }
8485
8486    #[test]
8487    fn tracing_filter_treats_empty_env_as_off() {
8488        assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
8489        assert_eq!(build_tracing_filter(Some("   ")).to_string(), "off");
8490    }
8491}