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