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