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