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