Skip to main content

fallow_cli/
lib.rs

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