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