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