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