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