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