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    let telemetry_run = start_telemetry_run(&cli, &fmt);
2641
2642    let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2643        Ok(v) => v,
2644        Err(code) => {
2645            return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2646        }
2647    };
2648
2649    let FormatConfig {
2650        output,
2651        payload_is_json: _,
2652        quiet,
2653        fail_on_issues,
2654        json_style,
2655    } = fmt;
2656
2657    let tolerance =
2658        match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2659            Ok(tolerance) => tolerance,
2660            Err(code) => return code,
2661        };
2662
2663    let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2664
2665    let command = cli.command.take();
2666    let dispatch = DispatchContext {
2667        cli: &cli,
2668        root: &root,
2669        output,
2670        quiet,
2671        fail_on_issues,
2672        json_style,
2673        threads,
2674        tolerance,
2675        save_regression_file: save_regression_file.as_ref(),
2676        save_to_config,
2677    };
2678    let exit_code = match dispatch_and_finalize(&dispatch, command) {
2679        Ok(code) => code,
2680        Err(code) => return code,
2681    };
2682    record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2683}
2684
2685/// Redirect the rendered report to `--output-file` (ambient sink), dispatch the
2686/// command, then flush+close the report file. Returns the dispatch exit code, or
2687/// `Err` carrying a redirect/finalize failure code for `main` to return directly.
2688fn dispatch_and_finalize(
2689    dispatch: &DispatchContext<'_>,
2690    command: Option<Command>,
2691) -> Result<ExitCode, ExitCode> {
2692    let cli = dispatch.cli;
2693    let output = dispatch.output;
2694    let quiet = dispatch.quiet;
2695
2696    // Set up the report-file sink before dispatch so rendering lands in the file;
2697    // progress and the confirmation stay on stderr.
2698    if let Some(path) = cli.output_file.as_deref()
2699        && let Err(code) = redirect_report_to_file(path, output)
2700    {
2701        return Err(code);
2702    }
2703
2704    let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2705        emit_error(bare_coverage_subcommand_error_message(), 2, output)
2706    } else {
2707        match command {
2708            None => dispatch_bare_command(dispatch),
2709            Some(cmd) => dispatch_subcommand(cmd, dispatch),
2710        }
2711    };
2712
2713    if let Some(path) = cli.output_file.as_deref()
2714        && let Err(code) = finalize_report_file(path, quiet, output)
2715    {
2716        return Err(code);
2717    }
2718    Ok(exit_code)
2719}
2720
2721fn run_telemetry_command_if_requested(
2722    cli: &mut Cli,
2723    output: fallow_config::OutputFormat,
2724    json_style: json_style::JsonStyle,
2725) -> Option<ExitCode> {
2726    if matches!(cli.command, Some(Command::Telemetry { .. }))
2727        && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2728    {
2729        return Some(telemetry::run(
2730            map_telemetry_subcommand(subcommand),
2731            output,
2732            json_style,
2733        ));
2734    }
2735    None
2736}
2737
2738fn run_schema_command_if_requested(
2739    cli: &Cli,
2740    json_style: json_style::JsonStyle,
2741) -> Option<ExitCode> {
2742    match cli.command {
2743        Some(Command::Schema) => Some(schema::run_schema(json_style)),
2744        Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2745        Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2746        Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2747        _ => None,
2748    }
2749}
2750
2751fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2752    let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2753        opt.as_ref()
2754            .filter(|path| !path.is_empty())
2755            .map(std::path::PathBuf::from)
2756    });
2757    let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2758    (save_file, save_to_config)
2759}
2760
2761fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2762    let cli = dispatch.cli;
2763    let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2764    let production = match dispatch.production_modes(
2765        cli.production_dead_code,
2766        cli.production_health,
2767        cli.production_dupes,
2768    ) {
2769        Ok(production) => production,
2770        Err(code) => return code,
2771    };
2772    let coverage_inputs = match resolve_health_coverage_inputs(
2773        dispatch,
2774        cli.coverage.as_deref(),
2775        cli.coverage_root.as_deref(),
2776    ) {
2777        Ok(inputs) => inputs,
2778        Err(code) => return code,
2779    };
2780    run_bare_combined(
2781        dispatch,
2782        production,
2783        &coverage_inputs,
2784        BareAnalyses {
2785            run_check,
2786            run_dupes,
2787            run_health,
2788        },
2789    )
2790}
2791
2792/// Which analyses the bare `fallow` run executes (resolved from `--only`/`--skip`).
2793#[derive(Clone, Copy)]
2794struct BareAnalyses {
2795    run_check: bool,
2796    run_dupes: bool,
2797    run_health: bool,
2798}
2799
2800/// Build `CombinedOptions` for a bare `fallow` invocation and run the combined
2801/// pipeline.
2802fn run_bare_combined(
2803    dispatch: &DispatchContext<'_>,
2804    production: ProductionModes,
2805    coverage_inputs: &ResolvedHealthCoverageInputs,
2806    analyses: BareAnalyses,
2807) -> ExitCode {
2808    let cli = dispatch.cli;
2809    let (output, quiet, fail_on_issues) =
2810        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
2811    combined::run_combined(&combined::CombinedOptions {
2812        root: dispatch.root,
2813        config_path: &cli.config,
2814        output,
2815        json_style: dispatch.json_style,
2816        no_cache: cli.no_cache,
2817        threads: dispatch.threads,
2818        quiet,
2819        allow_remote_extends: cli.allow_remote_extends,
2820        fail_on_issues,
2821        sarif_file: cli.sarif_file.as_deref(),
2822        changed_since: cli.changed_since.as_deref(),
2823        churn_file: cli.churn_file.as_deref(),
2824        baseline: cli.baseline.as_deref(),
2825        save_baseline: cli.save_baseline.as_deref(),
2826        production: cli.production,
2827        production_dead_code: Some(production.dead_code),
2828        production_health: Some(production.health),
2829        production_dupes: Some(production.dupes),
2830        workspace: cli.workspace.as_deref(),
2831        changed_workspaces: cli.changed_workspaces.as_deref(),
2832        group_by: cli.group_by,
2833        explain: cli.explain,
2834        explain_skipped: cli.explain_skipped,
2835        performance: cli.performance,
2836        summary: cli.summary,
2837        run_check: analyses.run_check,
2838        run_dupes: analyses.run_dupes,
2839        run_health: analyses.run_health,
2840        dupes_mode: cli.dupes_mode,
2841        dupes_threshold: cli.dupes_threshold,
2842        dupes_min_tokens: cli.dupes_min_tokens,
2843        dupes_min_lines: cli.dupes_min_lines,
2844        dupes_min_occurrences: cli.dupes_min_occurrences,
2845        dupes_skip_local: cli.dupes_skip_local,
2846        dupes_cross_language: cli.dupes_cross_language,
2847        dupes_ignore_imports: resolve_ignore_imports(
2848            cli.dupes_ignore_imports,
2849            cli.dupes_no_ignore_imports,
2850        ),
2851        score: cli.score || cli.trend,
2852        trend: cli.trend,
2853        save_snapshot: cli.save_snapshot.as_ref(),
2854        coverage: coverage_inputs.coverage.as_deref(),
2855        coverage_root: coverage_inputs.coverage_root.as_deref(),
2856        include_entry_exports: cli.include_entry_exports,
2857        regression_opts: dispatch.regression_opts(
2858            cli.changed_since.is_some()
2859                || cli.workspace.is_some()
2860                || cli.changed_workspaces.is_some(),
2861        ),
2862    })
2863}
2864
2865fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2866    let cli = dispatch.cli;
2867    let root = dispatch.root;
2868    let output = dispatch.output;
2869    let quiet = dispatch.quiet;
2870    match command {
2871        check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2872        Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2873        Command::Inspect {
2874            file,
2875            symbol,
2876            symbol_chain,
2877            churn,
2878        } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2879        Command::Trace {
2880            symbol,
2881            callers,
2882            callees,
2883            depth,
2884        } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2885        fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2886        init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2887        Command::Hooks { subcommand } => {
2888            run_hooks_command(root, subcommand, output, dispatch.json_style)
2889        }
2890        Command::Ci { subcommand } => {
2891            ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
2892        }
2893        Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
2894        Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
2895        Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
2896        Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
2897        Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2898        Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2899        Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2900        Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
2901            root,
2902            explicit_config: cli.config.as_deref(),
2903            path_only: path,
2904            output,
2905            quiet,
2906            json_style: dispatch.json_style,
2907            load_options: fallow_config::ConfigLoadOptions {
2908                allow_remote_extends: cli.allow_remote_extends,
2909            },
2910        }),
2911        Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
2912        list @ (Command::Workspaces | Command::List { .. }) => {
2913            dispatch_list_command(&list, dispatch)
2914        }
2915        dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2916        health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2917        Command::Flags { top } => dispatch_flags_command(dispatch, top),
2918        Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2919        Command::Explain { issue_type } => {
2920            explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
2921        }
2922        audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
2923        Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
2924        Command::DecisionSurface { max_decisions } => {
2925            dispatch_decision_surface(dispatch, max_decisions)
2926        }
2927        Command::Impact {
2928            subcommand,
2929            all,
2930            sort,
2931            limit,
2932        } => dispatch_impact(
2933            root,
2934            quiet,
2935            output,
2936            dispatch.json_style,
2937            subcommand,
2938            ImpactCrossRepoOpts { all, sort, limit },
2939        ),
2940        security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
2941        Command::Viz {
2942            output: viz_output,
2943            no_open,
2944            viz_format,
2945        } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
2946        Command::Report { from } => cli_report::run_report(&from, output, root),
2947        Command::Schema => unreachable!("handled above"),
2948        migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
2949        Command::License { subcommand } => {
2950            dispatch_license_command(subcommand, output, dispatch.json_style)
2951        }
2952        Command::Telemetry { .. } => unreachable!("handled before root validation"),
2953        Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
2954        setup_hooks @ Command::SetupHooks { .. } => {
2955            dispatch_setup_hooks_command(&setup_hooks, dispatch)
2956        }
2957    }
2958}
2959
2960/// Destructure the `Command::Check` arm and forward to `dispatch_check`.
2961fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2962    let filters = check_issue_filters(&command);
2963    let Command::Check {
2964        include_dupes,
2965        trace,
2966        trace_file,
2967        trace_dependency,
2968        impact_closure,
2969        top,
2970        file,
2971        ..
2972    } = command
2973    else {
2974        unreachable!("check dispatcher only handles check commands");
2975    };
2976
2977    dispatch_check(
2978        dispatch,
2979        &CheckDispatchArgs {
2980            filters,
2981            trace_opts: TraceOptions {
2982                trace_export: trace,
2983                trace_file,
2984                trace_dependency,
2985                impact_closure,
2986                performance: dispatch.cli.performance,
2987            },
2988            include_dupes,
2989            top,
2990            file,
2991        },
2992    )
2993}
2994
2995/// Map the `Command::Check` filter flags onto `IssueFilters`. Reads the flags by
2996/// reference (all `Copy` bools) so the caller can still move the non-filter
2997/// fields out of the same `Command` value afterwards. Split into two halves to
2998/// keep each builder within the unit-size limit.
2999fn check_issue_filters(command: &Command) -> IssueFilters {
3000    check_issue_filters_framework(command, &check_issue_filters_core(command))
3001}
3002
3003/// First half of the `IssueFilters` mapping: core/general filter flags over a
3004/// `Default` base. The framework/catalog half layers on top via struct update.
3005fn check_issue_filters_core(command: &Command) -> IssueFilters {
3006    let Command::Check {
3007        unused_files,
3008        unused_exports,
3009        unused_deps,
3010        unused_types,
3011        private_type_leaks,
3012        unused_enum_members,
3013        unused_class_members,
3014        unresolved_imports,
3015        unlisted_deps,
3016        duplicate_exports,
3017        circular_deps,
3018        re_export_cycles,
3019        boundary_violations,
3020        policy_violations,
3021        stale_suppressions,
3022        ..
3023    } = command
3024    else {
3025        unreachable!("check filter builder only handles check commands");
3026    };
3027
3028    let mut filters = IssueFilters::default();
3029    for (flag, active) in [
3030        ("--unused-files", *unused_files),
3031        ("--unused-exports", *unused_exports),
3032        ("--unused-deps", *unused_deps),
3033        ("--unused-types", *unused_types),
3034        ("--private-type-leaks", *private_type_leaks),
3035        ("--unused-enum-members", *unused_enum_members),
3036        ("--unused-class-members", *unused_class_members),
3037        ("--unresolved-imports", *unresolved_imports),
3038        ("--unlisted-deps", *unlisted_deps),
3039        ("--duplicate-exports", *duplicate_exports),
3040        ("--circular-deps", *circular_deps),
3041        ("--re-export-cycles", *re_export_cycles),
3042        ("--boundary-violations", *boundary_violations),
3043        ("--policy-violations", *policy_violations),
3044        ("--stale-suppressions", *stale_suppressions),
3045    ] {
3046        enable_check_filter(&mut filters, flag, active);
3047    }
3048    filters
3049}
3050
3051/// Second half of the `IssueFilters` mapping: framework/component, store, svelte,
3052/// catalog, and dependency-override flags, layered onto the core `base`.
3053fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3054    let Command::Check {
3055        unused_store_members,
3056        unprovided_injects,
3057        unrendered_components,
3058        unused_component_props,
3059        unused_component_emits,
3060        unused_component_inputs,
3061        unused_component_outputs,
3062        unused_svelte_events,
3063        unused_server_actions,
3064        unused_load_data_keys,
3065        unused_catalog_entries,
3066        empty_catalog_groups,
3067        unresolved_catalog_references,
3068        unused_dependency_overrides,
3069        misconfigured_dependency_overrides,
3070        ..
3071    } = command
3072    else {
3073        unreachable!("check filter builder only handles check commands");
3074    };
3075
3076    let mut filters = base.clone();
3077    for (flag, active) in [
3078        ("--unused-store-members", *unused_store_members),
3079        ("--unprovided-injects", *unprovided_injects),
3080        ("--unrendered-components", *unrendered_components),
3081        ("--unused-component-props", *unused_component_props),
3082        ("--unused-component-emits", *unused_component_emits),
3083        ("--unused-component-inputs", *unused_component_inputs),
3084        ("--unused-component-outputs", *unused_component_outputs),
3085        ("--unused-svelte-events", *unused_svelte_events),
3086        ("--unused-server-actions", *unused_server_actions),
3087        ("--unused-load-data-keys", *unused_load_data_keys),
3088        ("--unused-catalog-entries", *unused_catalog_entries),
3089        ("--empty-catalog-groups", *empty_catalog_groups),
3090        (
3091            "--unresolved-catalog-references",
3092            *unresolved_catalog_references,
3093        ),
3094        (
3095            "--unused-dependency-overrides",
3096            *unused_dependency_overrides,
3097        ),
3098        (
3099            "--misconfigured-dependency-overrides",
3100            *misconfigured_dependency_overrides,
3101        ),
3102    ] {
3103        enable_check_filter(&mut filters, flag, active);
3104    }
3105    filters
3106}
3107
3108fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3109    if active {
3110        assert!(
3111            filters.enable_cli_filter_flag(flag),
3112            "check command uses unregistered dead-code filter flag {flag}"
3113        );
3114    }
3115}
3116
3117fn dispatch_inspect_command(
3118    dispatch: &DispatchContext<'_>,
3119    file: Option<String>,
3120    symbol: Option<String>,
3121    symbol_chain: bool,
3122    churn: bool,
3123) -> ExitCode {
3124    let target = match (file, symbol) {
3125        (Some(file), None) => inspect::InspectTarget::File { file },
3126        (None, Some(symbol)) => match symbol.rsplit_once(':') {
3127            Some((file, export_name))
3128                if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3129            {
3130                inspect::InspectTarget::Symbol {
3131                    file: file.to_string(),
3132                    export_name: export_name.to_string(),
3133                }
3134            }
3135            _ => {
3136                return emit_error(
3137                    "--symbol must be formatted as FILE:EXPORT",
3138                    2,
3139                    dispatch.output,
3140                );
3141            }
3142        },
3143        _ => {
3144            return emit_error(
3145                "inspect requires exactly one of --file or --symbol",
3146                2,
3147                dispatch.output,
3148            );
3149        }
3150    };
3151
3152    let churn_config = if churn {
3153        match load_config_for_analysis(
3154            dispatch.root,
3155            &dispatch.cli.config,
3156            ConfigLoadOptions {
3157                output: dispatch.output,
3158                no_cache: dispatch.cli.no_cache,
3159                threads: dispatch.threads,
3160                production_override: None,
3161                quiet: dispatch.quiet,
3162                allow_remote_extends: dispatch.cli.allow_remote_extends,
3163            },
3164            fallow_config::ProductionAnalysis::Health,
3165        ) {
3166            Ok(config) => Some(config),
3167            Err(code) => return code,
3168        }
3169    } else {
3170        None
3171    };
3172
3173    inspect::run_inspect(&inspect::InspectOptions {
3174        root: dispatch.root,
3175        config_path: dispatch.cli.config.as_ref(),
3176        output: dispatch.output,
3177        json_style: dispatch.json_style,
3178        no_cache: dispatch.cli.no_cache,
3179        no_production: dispatch.cli.no_production,
3180        max_file_size: dispatch.cli.max_file_size,
3181        threads: dispatch.threads,
3182        quiet: dispatch.quiet,
3183        production: dispatch.cli.production,
3184        workspace: dispatch.cli.workspace.as_ref(),
3185        target,
3186        churn_cache_dir: churn_config
3187            .as_ref()
3188            .map(|config| config.cache_dir.as_path()),
3189        symbol_chain,
3190    })
3191}
3192
3193fn dispatch_trace_command(
3194    dispatch: &DispatchContext<'_>,
3195    symbol: String,
3196    callers: bool,
3197    callees: bool,
3198    depth: Option<u32>,
3199) -> ExitCode {
3200    trace_chain::run_trace(&trace_chain::TraceChainOptions {
3201        root: dispatch.root,
3202        config_path: &dispatch.cli.config,
3203        output: dispatch.output,
3204        json_style: dispatch.json_style,
3205        no_cache: dispatch.cli.no_cache,
3206        threads: dispatch.threads,
3207        quiet: dispatch.quiet,
3208        allow_remote_extends: dispatch.cli.allow_remote_extends,
3209        target: symbol,
3210        callers,
3211        callees,
3212        depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3213    })
3214}
3215
3216fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3217    let Command::Security {
3218        subcommand,
3219        runtime_coverage,
3220        min_invocations_hot,
3221        file,
3222        gate,
3223        surface,
3224    } = command
3225    else {
3226        unreachable!("security dispatcher only handles security commands");
3227    };
3228
3229    let gate = gate.map(security::SecurityGateArg::into_mode);
3230    let cli = dispatch.cli;
3231    let (output, _quiet, fail_on_issues) =
3232        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3233    let derived_flags = SecurityDerivedFlagState {
3234        output,
3235        json_style: dispatch.json_style,
3236        ci: cli.ci,
3237        fail_on_issues,
3238        sarif_file: cli.sarif_file.as_deref(),
3239        summary: cli.summary,
3240        explain: cli.explain,
3241        runtime_coverage: runtime_coverage.as_deref(),
3242        min_invocations_hot,
3243        file: file.as_slice(),
3244        gate,
3245        surface,
3246    };
3247    if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3248        return code;
3249    }
3250
3251    let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3252    run_security_blind_spots_or_default(
3253        dispatch,
3254        &SecurityRunInputs {
3255            scoped_files: &scoped_files,
3256            subcommand: &subcommand,
3257            runtime_coverage: runtime_coverage.as_deref(),
3258            min_invocations_hot,
3259            gate,
3260            surface,
3261        },
3262        &derived_flags,
3263    )
3264}
3265
3266/// Inputs threaded from the security dispatcher into the run step. Borrows the
3267/// scoped file list and subcommand so they outlive the `SecurityOptions`.
3268struct SecurityRunInputs<'a> {
3269    scoped_files: &'a [PathBuf],
3270    subcommand: &'a Option<SecuritySubcommand>,
3271    runtime_coverage: Option<&'a Path>,
3272    min_invocations_hot: u64,
3273    gate: Option<security::SecurityGateMode>,
3274    surface: bool,
3275}
3276
3277/// Build `SecurityOptions` and run either the blind-spots or default analysis.
3278fn run_security_blind_spots_or_default(
3279    dispatch: &DispatchContext<'_>,
3280    inputs: &SecurityRunInputs<'_>,
3281    derived_flags: &SecurityDerivedFlagState<'_>,
3282) -> ExitCode {
3283    let cli = dispatch.cli;
3284    let (output, quiet, fail_on_issues) =
3285        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3286    let opts = security::SecurityOptions {
3287        root: dispatch.root,
3288        config_path: &cli.config,
3289        output,
3290        json_style: dispatch.json_style,
3291        no_cache: cli.no_cache,
3292        threads: dispatch.threads,
3293        quiet,
3294        allow_remote_extends: cli.allow_remote_extends,
3295        fail_on_issues,
3296        sarif_file: cli.sarif_file.as_deref(),
3297        summary: cli.summary,
3298        changed_since: cli.changed_since.as_deref(),
3299        use_shared_diff_index: true,
3300        workspace: cli.workspace.as_deref(),
3301        changed_workspaces: cli.changed_workspaces.as_deref(),
3302        file: inputs.scoped_files,
3303        surface: inputs.surface,
3304        gate: inputs.gate,
3305        runtime_coverage: inputs.runtime_coverage,
3306        min_invocations_hot: inputs.min_invocations_hot,
3307        explain: cli.explain,
3308    };
3309    if matches!(
3310        inputs.subcommand,
3311        Some(SecuritySubcommand::BlindSpots { .. })
3312    ) {
3313        if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3314            return code;
3315        }
3316        security::run_blind_spots(&opts)
3317    } else {
3318        security::run(&opts)
3319    }
3320}
3321
3322/// Handle `fallow security survivors` as an early return. Returns `Some(code)`
3323/// when the subcommand is `survivors` (validated then run); `None` otherwise.
3324fn try_run_security_survivors(
3325    subcommand: Option<&SecuritySubcommand>,
3326    flags: &SecurityDerivedFlagState<'_>,
3327) -> Option<ExitCode> {
3328    let Some(SecuritySubcommand::Survivors {
3329        candidates,
3330        verdicts,
3331        require_verdict_for_each_candidate,
3332    }) = subcommand
3333    else {
3334        return None;
3335    };
3336    if let Some(code) = validate_security_survivors_flags(flags) {
3337        return Some(code);
3338    }
3339    Some(security::run_survivors(
3340        &security::SecuritySurvivorsOptions {
3341            output: flags.output,
3342            json_style: flags.json_style,
3343            candidates,
3344            verdicts,
3345            require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3346        },
3347    ))
3348}
3349
3350/// Build the scoped file list, folding in `blind-spots` extra `--file` values.
3351fn scoped_security_files(
3352    file: &[PathBuf],
3353    subcommand: Option<&SecuritySubcommand>,
3354) -> Vec<PathBuf> {
3355    let mut scoped_files = file.to_vec();
3356    if let Some(SecuritySubcommand::BlindSpots {
3357        file: blind_spot_files,
3358    }) = subcommand
3359    {
3360        scoped_files.extend(blind_spot_files.iter().cloned());
3361    }
3362    scoped_files
3363}
3364
3365struct SecurityDerivedFlagState<'a> {
3366    output: fallow_config::OutputFormat,
3367    json_style: json_style::JsonStyle,
3368    ci: bool,
3369    fail_on_issues: bool,
3370    sarif_file: Option<&'a Path>,
3371    summary: bool,
3372    explain: bool,
3373    runtime_coverage: Option<&'a Path>,
3374    min_invocations_hot: u64,
3375    file: &'a [PathBuf],
3376    gate: Option<security::SecurityGateMode>,
3377    surface: bool,
3378}
3379
3380fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3381    let flag = if flags.ci {
3382        Some("--ci")
3383    } else if flags.fail_on_issues {
3384        Some("--fail-on-issues")
3385    } else if flags.sarif_file.is_some() {
3386        Some("--sarif-file")
3387    } else if flags.summary {
3388        Some("--summary")
3389    } else if flags.explain {
3390        Some("--explain")
3391    } else if flags.runtime_coverage.is_some() {
3392        Some("--runtime-coverage")
3393    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3394        Some("--min-invocations-hot")
3395    } else if !flags.file.is_empty() {
3396        Some("--file")
3397    } else if flags.gate.is_some() {
3398        Some("--gate")
3399    } else if flags.surface {
3400        Some("--surface")
3401    } else {
3402        None
3403    }?;
3404    Some(emit_error(
3405        &format!("{flag} is not valid with `fallow security survivors`."),
3406        2,
3407        flags.output,
3408    ))
3409}
3410
3411fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3412    let flag = if flags.ci {
3413        Some("--ci")
3414    } else if flags.fail_on_issues {
3415        Some("--fail-on-issues")
3416    } else if flags.sarif_file.is_some() {
3417        Some("--sarif-file")
3418    } else if flags.summary {
3419        Some("--summary")
3420    } else if flags.explain {
3421        Some("--explain")
3422    } else if flags.runtime_coverage.is_some() {
3423        Some("--runtime-coverage")
3424    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3425        Some("--min-invocations-hot")
3426    } else if flags.gate.is_some() {
3427        Some("--gate")
3428    } else if flags.surface {
3429        Some("--surface")
3430    } else {
3431        None
3432    }?;
3433    Some(emit_error(
3434        &format!("{flag} is not valid with `fallow security blind-spots`."),
3435        2,
3436        flags.output,
3437    ))
3438}
3439
3440fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3441    let Command::Dupes {
3442        mode,
3443        min_tokens,
3444        min_lines,
3445        min_occurrences,
3446        threshold,
3447        skip_local,
3448        cross_language,
3449        ignore_imports,
3450        no_ignore_imports,
3451        top,
3452        trace,
3453    } = command
3454    else {
3455        unreachable!("dupes dispatcher only handles dupes commands");
3456    };
3457
3458    dispatch_dupes(
3459        dispatch,
3460        &DupesDispatchArgs {
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        },
3473    )
3474}
3475
3476fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3477    let Command::Init {
3478        toml,
3479        agents,
3480        hooks,
3481        branch,
3482        decline,
3483    } = command
3484    else {
3485        unreachable!("init dispatcher only handles init commands");
3486    };
3487
3488    init::run_init(&init::InitOptions {
3489        root,
3490        use_toml: toml,
3491        agents,
3492        hooks,
3493        branch: branch.as_deref(),
3494        decline,
3495        quiet,
3496    })
3497}
3498
3499fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3500    let Command::Fix {
3501        dry_run,
3502        yes,
3503        no_create_config,
3504    } = command
3505    else {
3506        unreachable!("fix dispatcher only handles fix commands");
3507    };
3508
3509    dispatch_fix(
3510        dispatch,
3511        FixDispatchArgs {
3512            dry_run: *dry_run,
3513            yes: *yes,
3514            no_create_config: *no_create_config,
3515        },
3516    )
3517}
3518
3519fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3520    match command {
3521        Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3522        Command::List {
3523            entry_points,
3524            files,
3525            plugins,
3526            boundaries,
3527            workspaces,
3528        } => dispatch_list(
3529            dispatch,
3530            ListDispatchArgs {
3531                entry_points: *entry_points,
3532                files: *files,
3533                plugins: *plugins,
3534                boundaries: *boundaries,
3535                workspaces: *workspaces,
3536            },
3537        ),
3538        _ => unreachable!("list dispatcher only handles list commands"),
3539    }
3540}
3541
3542fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3543    let Command::Migrate {
3544        toml,
3545        jsonc,
3546        dry_run,
3547        from,
3548    } = command
3549    else {
3550        unreachable!("migrate dispatcher only handles migrate commands");
3551    };
3552
3553    migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3554}
3555
3556fn dispatch_license_command(
3557    subcommand: LicenseCli,
3558    output: fallow_config::OutputFormat,
3559    json_style: json_style::JsonStyle,
3560) -> ExitCode {
3561    license::run(&map_license_subcommand(subcommand), output, json_style)
3562}
3563
3564fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3565    match subcommand {
3566        CiTemplateCli::Gitlab { vendor, force } => {
3567            ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3568                vendor_dir: vendor,
3569                force,
3570            })
3571        }
3572    }
3573}
3574
3575fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3576    let cli = dispatch.cli;
3577    coverage::run(
3578        map_coverage_subcommand(subcommand, cli.explain),
3579        &coverage::RunContext {
3580            root: dispatch.root,
3581            config_path: &cli.config,
3582            output: dispatch.output,
3583            json_style: dispatch.json_style,
3584            quiet: dispatch.quiet,
3585            no_cache: cli.no_cache,
3586            threads: dispatch.threads,
3587            explain: cli.explain,
3588            allow_remote_extends: cli.allow_remote_extends,
3589        },
3590    )
3591}
3592
3593fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3594    let Command::Health {
3595        max_cyclomatic,
3596        max_cognitive,
3597        max_crap,
3598        top,
3599        sort,
3600        complexity,
3601        complexity_breakdown,
3602        file_scores,
3603        coverage_gaps,
3604        hotspots,
3605        ownership,
3606        ownership_emails,
3607        targets,
3608        css,
3609        effort,
3610        score,
3611        min_score,
3612        min_severity,
3613        report_only,
3614        since,
3615        min_commits,
3616        save_snapshot,
3617        trend,
3618        coverage,
3619        coverage_root,
3620        runtime_coverage,
3621        min_invocations_hot,
3622        min_observation_volume,
3623        low_traffic_threshold,
3624    } = command
3625    else {
3626        unreachable!("health dispatcher only handles health commands");
3627    };
3628
3629    let ownership = ownership || ownership_emails.is_some();
3630    let hotspots = hotspots || ownership;
3631    let args = HealthDispatchArgs {
3632        max_cyclomatic,
3633        max_cognitive,
3634        max_crap,
3635        top,
3636        sort,
3637        complexity,
3638        complexity_breakdown,
3639        file_scores,
3640        coverage_gaps,
3641        hotspots,
3642        ownership,
3643        ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3644        targets,
3645        css,
3646        effort,
3647        score,
3648        min_score,
3649        min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3650        report_only,
3651        since: since.as_deref(),
3652        min_commits,
3653        save_snapshot: save_snapshot.as_ref(),
3654        trend,
3655        coverage: coverage.as_deref(),
3656        coverage_root: coverage_root.as_deref(),
3657        runtime_coverage: runtime_coverage.as_deref(),
3658        min_invocations_hot,
3659        min_observation_volume,
3660        low_traffic_threshold,
3661    };
3662    dispatch_health(dispatch, &args)
3663}
3664
3665fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3666    let Command::SetupHooks {
3667        agent,
3668        dry_run,
3669        force,
3670        user,
3671        gitignore_claude,
3672        uninstall,
3673    } = command
3674    else {
3675        unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3676    };
3677
3678    setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3679        root: dispatch.root,
3680        agent: *agent,
3681        dry_run: *dry_run,
3682        force: *force,
3683        user: *user,
3684        gitignore_claude: *gitignore_claude,
3685        uninstall: *uninstall,
3686    })
3687}
3688
3689fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3690    let Command::Audit {
3691        production_dead_code,
3692        production_health,
3693        production_dupes,
3694        dead_code_baseline,
3695        health_baseline,
3696        dupes_baseline,
3697        max_crap,
3698        coverage,
3699        coverage_root,
3700        no_css,
3701        css_deep,
3702        no_css_deep,
3703        gate,
3704        runtime_coverage,
3705        min_invocations_hot,
3706        gate_marker,
3707        brief,
3708        max_decisions,
3709        walkthrough_guide,
3710        walkthrough_file,
3711        walkthrough,
3712        mark_viewed,
3713        show_cleared,
3714        show_deprioritized,
3715    } = command
3716    else {
3717        unreachable!("audit dispatcher only handles audit commands");
3718    };
3719
3720    // The walkthrough flags imply the brief path (the guide digest + the
3721    // graph-snapshot pin are brief-path data).
3722    let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3723
3724    dispatch_audit(
3725        dispatch,
3726        &AuditDispatchArgs {
3727            production_dead_code,
3728            production_health,
3729            production_dupes,
3730            dead_code_baseline,
3731            health_baseline,
3732            dupes_baseline,
3733            max_crap,
3734            coverage,
3735            coverage_root,
3736            no_css,
3737            css_deep,
3738            no_css_deep,
3739            gate,
3740            runtime_coverage,
3741            min_invocations_hot,
3742            gate_marker,
3743            brief,
3744            max_decisions,
3745            walkthrough_guide,
3746            walkthrough_file,
3747            walkthrough,
3748            mark_viewed,
3749            show_cleared,
3750            show_deprioritized,
3751        },
3752    )
3753}
3754
3755fn dispatch_audit_cache_command(
3756    dispatch: &DispatchContext<'_>,
3757    subcommand: &AuditCacheCli,
3758) -> ExitCode {
3759    match subcommand {
3760        AuditCacheCli::Remove { dry_run, yes } => {
3761            if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
3762                return emit_error(
3763                    "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
3764                    2,
3765                    dispatch.output,
3766                );
3767            }
3768            match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
3769                Ok(report) => {
3770                    let action = if *dry_run { "would remove" } else { "removed" };
3771                    if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
3772                        let value = serde_json::json!({
3773                            "kind": "audit-cache-remove",
3774                            "schema_version": 1,
3775                            "command": "audit-cache remove",
3776                            "root": dispatch.root,
3777                            "dry_run": report.dry_run,
3778                            "found": report.found,
3779                            "would_remove": report.found.saturating_sub(report.skipped),
3780                            "removed": report.removed,
3781                            "skipped": report.skipped,
3782                            "complete": report.skipped == 0,
3783                        });
3784                        let output_code = report::emit_report_json(
3785                            &value,
3786                            "audit cache removal",
3787                            dispatch.json_style,
3788                        );
3789                        if output_code != ExitCode::SUCCESS {
3790                            return output_code;
3791                        }
3792                    } else if !dispatch.quiet {
3793                        println!(
3794                            "audit cache: {action} {}, skipped {} for {}",
3795                            if *dry_run {
3796                                report.found.saturating_sub(report.skipped)
3797                            } else {
3798                                report.removed
3799                            },
3800                            report.skipped,
3801                            dispatch.root.display(),
3802                        );
3803                    }
3804                    if report.skipped == 0 {
3805                        ExitCode::SUCCESS
3806                    } else {
3807                        ExitCode::from(2)
3808                    }
3809                }
3810                Err(error) => emit_error(
3811                    &format!(
3812                        "failed to remove audit caches for {}: {error}",
3813                        dispatch.root.display()
3814                    ),
3815                    2,
3816                    dispatch.output,
3817                ),
3818            }
3819        }
3820    }
3821}
3822
3823fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3824    let cli = dispatch.cli;
3825    let root = dispatch.root;
3826    let output = dispatch.output;
3827    let quiet = dispatch.quiet;
3828    let threads = dispatch.threads;
3829    let production = match resolve_production_modes(cli, root, output, false, false, false) {
3830        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3831        Err(code) => return code,
3832    };
3833    flags::run_flags(&flags::FlagsOptions {
3834        root,
3835        config_path: &cli.config,
3836        output,
3837        json_style: dispatch.json_style,
3838        no_cache: cli.no_cache,
3839        threads,
3840        quiet,
3841        allow_remote_extends: cli.allow_remote_extends,
3842        production,
3843        workspace: cli.workspace.as_deref(),
3844        changed_workspaces: cli.changed_workspaces.as_deref(),
3845        changed_since: cli.changed_since.as_deref(),
3846        explain: cli.explain,
3847        top,
3848    })
3849}
3850
3851fn dispatch_suppressions_command(
3852    dispatch: &DispatchContext<'_>,
3853    file: &[std::path::PathBuf],
3854) -> ExitCode {
3855    let cli = dispatch.cli;
3856    let root = dispatch.root;
3857    let output = dispatch.output;
3858    let production = match resolve_production_modes(cli, root, output, false, false, false) {
3859        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3860        Err(code) => return code,
3861    };
3862    suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3863        root,
3864        config_path: &cli.config,
3865        output,
3866        json_style: dispatch.json_style,
3867        no_cache: cli.no_cache,
3868        threads: dispatch.threads,
3869        quiet: dispatch.quiet,
3870        allow_remote_extends: cli.allow_remote_extends,
3871        production,
3872        workspace: cli.workspace.as_deref(),
3873        changed_workspaces: cli.changed_workspaces.as_deref(),
3874        changed_since: cli.changed_since.as_deref(),
3875        file,
3876    })
3877}
3878
3879fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3880    guard::run_guard(&guard::GuardOptions {
3881        root: dispatch.root,
3882        config_path: &dispatch.cli.config,
3883        output: dispatch.output,
3884        json_style: dispatch.json_style,
3885        quiet: dispatch.quiet,
3886        allow_remote_extends: dispatch.cli.allow_remote_extends,
3887        files,
3888    })
3889}
3890
3891fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3892    let ctx = rule_pack::RulePackContext {
3893        root: dispatch.root,
3894        config_path: &dispatch.cli.config,
3895        output: dispatch.output,
3896        json_style: dispatch.json_style,
3897        quiet: dispatch.quiet,
3898        no_cache: dispatch.cli.no_cache,
3899        threads: Some(dispatch.threads),
3900        allow_remote_extends: dispatch.cli.allow_remote_extends,
3901    };
3902    rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3903}
3904
3905fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3906    match subcommand {
3907        RulePackCli::Init {
3908            name,
3909            template,
3910            dir,
3911            no_config,
3912        } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3913            name,
3914            template,
3915            dir,
3916            no_config,
3917        }),
3918        RulePackCli::List => rule_pack::RulePackSubcommand::List,
3919        RulePackCli::Test { pack } => {
3920            rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3921        }
3922        RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3923    }
3924}
3925
3926fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3927    match sub {
3928        LicenseCli::Activate {
3929            jwt,
3930            from_file,
3931            stdin,
3932            trial,
3933            email,
3934        } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3935            raw_jwt: jwt,
3936            from_file,
3937            from_stdin: stdin,
3938            trial,
3939            email,
3940        }),
3941        LicenseCli::Status => license::LicenseSubcommand::Status,
3942        LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3943        LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3944    }
3945}
3946
3947fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3948    match sub {
3949        TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3950        TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3951        TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3952        TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3953    }
3954}
3955
3956fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3957    match sub {
3958        command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3959        command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3960        command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3961        command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3962        command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3963    }
3964}
3965
3966fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3967    let CiCli::PlanPrComment {
3968        body,
3969        marker_id,
3970        clean,
3971        existing_comment_id,
3972        existing_body,
3973    } = command
3974    else {
3975        unreachable!("ci plan-pr-comment mapper called with different variant");
3976    };
3977
3978    ci::CiCommand::PlanPrComment {
3979        body,
3980        marker_id,
3981        clean,
3982        existing_comment_id,
3983        existing_body,
3984    }
3985}
3986
3987fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3988    let CiCli::PostPrComment {
3989        provider,
3990        pr,
3991        mr,
3992        body,
3993        envelope,
3994        marker_id,
3995        clean,
3996        repo,
3997        project_id,
3998        api_url,
3999        dry_run,
4000    } = command
4001    else {
4002        unreachable!("ci post-pr-comment mapper called with different variant");
4003    };
4004
4005    ci::CiCommand::PostPrComment {
4006        provider: map_ci_provider(provider),
4007        target: pr.or(mr),
4008        body,
4009        envelope,
4010        marker_id,
4011        clean,
4012        repo,
4013        project_id,
4014        api_url,
4015        dry_run,
4016    }
4017}
4018
4019fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4020    let CiCli::PostReview {
4021        provider,
4022        pr,
4023        mr,
4024        envelope,
4025        repo,
4026        project_id,
4027        api_url,
4028        dry_run,
4029    } = command
4030    else {
4031        unreachable!("ci post-review mapper called with different variant");
4032    };
4033
4034    ci::CiCommand::PostReview {
4035        provider: map_ci_provider(provider),
4036        target: pr.or(mr),
4037        envelope,
4038        repo,
4039        project_id,
4040        api_url,
4041        dry_run,
4042    }
4043}
4044
4045fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4046    let CiCli::PostCheckRun {
4047        provider,
4048        decision,
4049        repo,
4050        head_sha,
4051        api_url,
4052        split_gates,
4053        dry_run,
4054    } = command
4055    else {
4056        unreachable!("ci post-check-run mapper called with different variant");
4057    };
4058
4059    ci::CiCommand::PostCheckRun {
4060        provider: map_ci_provider(provider),
4061        decision,
4062        repo,
4063        head_sha,
4064        api_url,
4065        split_gates,
4066        dry_run,
4067    }
4068}
4069
4070fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4071    let CiCli::ReconcileReview {
4072        provider,
4073        pr,
4074        mr,
4075        envelope,
4076        repo,
4077        project_id,
4078        api_url,
4079        dry_run,
4080    } = command
4081    else {
4082        unreachable!("ci reconcile-review mapper called with different variant");
4083    };
4084
4085    ci::CiCommand::ReconcileReview {
4086        provider: map_ci_provider(provider),
4087        target: pr.or(mr),
4088        envelope,
4089        repo,
4090        project_id,
4091        api_url,
4092        dry_run,
4093    }
4094}
4095
4096fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4097    match provider {
4098        CiProviderArg::Github => ci::CiProvider::Github,
4099        CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4100    }
4101}
4102
4103fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4104    match sub {
4105        CoverageCli::Setup {
4106            yes,
4107            non_interactive,
4108            json,
4109        } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4110        CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4111        CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4112        CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4113        CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4114    }
4115}
4116
4117fn map_coverage_setup(
4118    yes: bool,
4119    non_interactive: bool,
4120    json: bool,
4121    explain: bool,
4122) -> coverage::CoverageSubcommand {
4123    coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4124        yes,
4125        non_interactive: non_interactive || json,
4126        json,
4127        explain,
4128    })
4129}
4130
4131fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4132    let CoverageCli::Analyze {
4133        runtime_coverage,
4134        cloud,
4135        api_key,
4136        api_endpoint,
4137        repo,
4138        project_id,
4139        coverage_period,
4140        environment,
4141        commit_sha,
4142        production,
4143        min_invocations_hot,
4144        min_observation_volume,
4145        low_traffic_threshold,
4146        top,
4147        blast_radius,
4148        importance,
4149    } = sub
4150    else {
4151        unreachable!("coverage analyze mapper called with non-analyze variant");
4152    };
4153    coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4154        runtime_coverage: runtime_coverage.clone(),
4155        cloud: *cloud,
4156        api_key: api_key.clone(),
4157        api_endpoint: api_endpoint.clone(),
4158        repo: repo.clone(),
4159        project_id: project_id.clone(),
4160        coverage_period: *coverage_period,
4161        environment: environment.clone(),
4162        commit_sha: commit_sha.clone(),
4163        production: *production,
4164        min_invocations_hot: *min_invocations_hot,
4165        min_observation_volume: *min_observation_volume,
4166        low_traffic_threshold: *low_traffic_threshold,
4167        top: *top,
4168        blast_radius: *blast_radius,
4169        importance: *importance,
4170    })
4171}
4172
4173fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4174    let CoverageCli::UploadInventory {
4175        api_key,
4176        api_endpoint,
4177        project_id,
4178        git_sha,
4179        allow_dirty,
4180        exclude_paths,
4181        path_prefix,
4182        dry_run,
4183        with_callers,
4184        ignore_upload_errors,
4185    } = sub
4186    else {
4187        unreachable!("coverage inventory mapper called with non-inventory variant");
4188    };
4189    coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4190        api_key: api_key.clone(),
4191        api_endpoint: api_endpoint.clone(),
4192        project_id: project_id.clone(),
4193        git_sha: git_sha.clone(),
4194        allow_dirty: *allow_dirty,
4195        exclude_paths: exclude_paths.clone(),
4196        path_prefix: path_prefix.clone(),
4197        dry_run: *dry_run,
4198        with_callers: *with_callers,
4199        ignore_upload_errors: *ignore_upload_errors,
4200    })
4201}
4202
4203fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4204    let CoverageCli::UploadSourceMaps {
4205        dir,
4206        include,
4207        exclude,
4208        repo,
4209        git_sha,
4210        endpoint,
4211        strip_path,
4212        dry_run,
4213        concurrency,
4214        fail_fast,
4215    } = sub
4216    else {
4217        unreachable!("coverage source-map mapper called with non-source-map variant");
4218    };
4219    coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4220        dir: dir.clone(),
4221        include: include.clone(),
4222        exclude: exclude.clone(),
4223        repo: repo.clone(),
4224        git_sha: git_sha.clone(),
4225        endpoint: endpoint.clone(),
4226        strip_path: *strip_path,
4227        dry_run: *dry_run,
4228        concurrency: *concurrency,
4229        fail_fast: *fail_fast,
4230    })
4231}
4232
4233fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4234    let CoverageCli::UploadStaticFindings {
4235        api_key,
4236        api_endpoint,
4237        project_id,
4238        git_sha,
4239        allow_dirty,
4240        dry_run,
4241        ignore_upload_errors,
4242    } = sub
4243    else {
4244        unreachable!("coverage static-findings mapper called with non-static variant");
4245    };
4246    coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4247        api_key: api_key.clone(),
4248        api_endpoint: api_endpoint.clone(),
4249        project_id: project_id.clone(),
4250        git_sha: git_sha.clone(),
4251        allow_dirty: *allow_dirty,
4252        dry_run: *dry_run,
4253        ignore_upload_errors: *ignore_upload_errors,
4254    })
4255}
4256
4257struct CheckDispatchArgs {
4258    filters: IssueFilters,
4259    trace_opts: TraceOptions,
4260    include_dupes: bool,
4261    top: Option<usize>,
4262    file: Vec<std::path::PathBuf>,
4263}
4264
4265#[derive(Clone, Copy)]
4266struct ListDispatchArgs {
4267    entry_points: bool,
4268    files: bool,
4269    plugins: bool,
4270    boundaries: bool,
4271    workspaces: bool,
4272}
4273
4274impl ListDispatchArgs {
4275    fn workspaces() -> Self {
4276        Self {
4277            entry_points: false,
4278            files: false,
4279            plugins: false,
4280            boundaries: false,
4281            workspaces: true,
4282        }
4283    }
4284}
4285
4286fn dispatch_viz(
4287    dispatch: &DispatchContext<'_>,
4288    output_path: Option<&std::path::Path>,
4289    no_open: bool,
4290    format: viz::VizFormat,
4291) -> ExitCode {
4292    let cli = dispatch.cli;
4293    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4294        Ok(production) => production,
4295        Err(code) => return code,
4296    };
4297    viz::run_viz(&viz::VizOptions {
4298        root: dispatch.root,
4299        config_path: &cli.config,
4300        no_cache: cli.no_cache,
4301        threads: dispatch.threads,
4302        quiet: dispatch.quiet,
4303        production,
4304        allow_remote_extends: cli.allow_remote_extends,
4305        output_path,
4306        no_open,
4307        format,
4308    })
4309}
4310
4311fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4312    let cli = dispatch.cli;
4313    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4314        Ok(production) => production,
4315        Err(code) => return code,
4316    };
4317    watch::run_watch(&watch::WatchOptions {
4318        root: dispatch.root,
4319        config_path: &cli.config,
4320        output: dispatch.output,
4321        json_style: dispatch.json_style,
4322        no_cache: cli.no_cache,
4323        threads: dispatch.threads,
4324        quiet: dispatch.quiet,
4325        allow_remote_extends: cli.allow_remote_extends,
4326        production,
4327        clear_screen: !no_clear,
4328        explain: cli.explain,
4329        include_entry_exports: cli.include_entry_exports,
4330    })
4331}
4332
4333#[derive(Clone, Copy)]
4334struct FixDispatchArgs {
4335    dry_run: bool,
4336    yes: bool,
4337    no_create_config: bool,
4338}
4339
4340fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4341    let cli = dispatch.cli;
4342    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4343        Ok(production) => production,
4344        Err(code) => return code,
4345    };
4346    fix::run_fix(&fix::FixOptions {
4347        root: dispatch.root,
4348        config_path: &cli.config,
4349        output: dispatch.output,
4350        json_style: dispatch.json_style,
4351        no_cache: cli.no_cache,
4352        threads: dispatch.threads,
4353        quiet: dispatch.quiet,
4354        allow_remote_extends: cli.allow_remote_extends,
4355        dry_run: args.dry_run,
4356        yes: args.yes,
4357        production,
4358        no_create_config: args.no_create_config,
4359    })
4360}
4361
4362fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4363    let cli = dispatch.cli;
4364    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4365        Ok(production) => production,
4366        Err(code) => return code,
4367    };
4368    list::run_list(&ListOptions {
4369        root: dispatch.root,
4370        config_path: &cli.config,
4371        output: dispatch.output,
4372        json_style: dispatch.json_style,
4373        threads: dispatch.threads,
4374        no_cache: cli.no_cache,
4375        entry_points: args.entry_points,
4376        files: args.files,
4377        plugins: args.plugins,
4378        boundaries: args.boundaries,
4379        workspaces: args.workspaces,
4380        production,
4381        allow_remote_extends: cli.allow_remote_extends,
4382    })
4383}
4384
4385fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4386    let cli = dispatch.cli;
4387    let (output, quiet, fail_on_issues) =
4388        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4389    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4390        Ok(production) => production,
4391        Err(code) => return code,
4392    };
4393    check::run_check(&CheckOptions {
4394        root: dispatch.root,
4395        config_path: &cli.config,
4396        output,
4397        json_style: dispatch.json_style,
4398        no_cache: cli.no_cache,
4399        threads: dispatch.threads,
4400        quiet,
4401        allow_remote_extends: cli.allow_remote_extends,
4402        fail_on_issues,
4403        filters: &args.filters,
4404        changed_since: cli.changed_since.as_deref(),
4405        diff_index: None,
4406        use_shared_diff_index: true,
4407        baseline: cli.baseline.as_deref(),
4408        save_baseline: cli.save_baseline.as_deref(),
4409        sarif_file: cli.sarif_file.as_deref(),
4410        production,
4411        production_override: Some(production),
4412        workspace: cli.workspace.as_deref(),
4413        changed_workspaces: cli.changed_workspaces.as_deref(),
4414        group_by: cli.group_by,
4415        include_dupes: args.include_dupes,
4416        trace_opts: &args.trace_opts,
4417        explain: cli.explain,
4418        top: args.top,
4419        file: &args.file,
4420        include_entry_exports: cli.include_entry_exports,
4421        summary: cli.summary,
4422        regression_opts: dispatch.regression_opts(
4423            cli.changed_since.is_some()
4424                || cli.workspace.is_some()
4425                || cli.changed_workspaces.is_some()
4426                || !args.file.is_empty(),
4427        ),
4428        retain_modules_for_health: false,
4429        defer_performance: false,
4430    })
4431}
4432
4433/// Resolve the three-state `ignoreImports` CLI override from the opt-in /
4434/// opt-out flag pair. clap's `conflicts_with` guarantees the two are never both
4435/// set, so this maps `--no-ignore-imports` -> `Some(false)`, `--ignore-imports`
4436/// -> `Some(true)`, and neither -> `None` (defer to config, which defaults to
4437/// `true`).
4438fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4439    if no_ignore_imports {
4440        Some(false)
4441    } else if ignore_imports {
4442        Some(true)
4443    } else {
4444        None
4445    }
4446}
4447
4448struct DupesDispatchArgs {
4449    mode: Option<DupesMode>,
4450    min_tokens: Option<usize>,
4451    min_lines: Option<usize>,
4452    min_occurrences: Option<usize>,
4453    threshold: Option<f64>,
4454    skip_local: bool,
4455    cross_language: bool,
4456    ignore_imports: bool,
4457    no_ignore_imports: bool,
4458    top: Option<usize>,
4459    trace: Option<String>,
4460}
4461
4462fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4463    let cli = dispatch.cli;
4464    let (output, quiet, _fail_on_issues) =
4465        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4466    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4467        Ok(production) => production,
4468        Err(code) => return code,
4469    };
4470    dupes::run_dupes(&DupesOptions {
4471        root: dispatch.root,
4472        config_path: &cli.config,
4473        output,
4474        json_style: dispatch.json_style,
4475        no_cache: cli.no_cache,
4476        threads: dispatch.threads,
4477        quiet,
4478        allow_remote_extends: cli.allow_remote_extends,
4479        mode: args.mode,
4480        min_tokens: args.min_tokens,
4481        min_lines: args.min_lines,
4482        min_occurrences: args.min_occurrences,
4483        threshold: args.threshold,
4484        skip_local: args.skip_local,
4485        cross_language: args.cross_language,
4486        ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4487        top: args.top,
4488        baseline_path: cli.baseline.as_deref(),
4489        save_baseline_path: cli.save_baseline.as_deref(),
4490        production,
4491        production_override: Some(production),
4492        trace: args.trace.as_deref(),
4493        changed_since: cli.changed_since.as_deref(),
4494        diff_index: None,
4495        use_shared_diff_index: true,
4496        changed_files: None,
4497        workspace: cli.workspace.as_deref(),
4498        changed_workspaces: cli.changed_workspaces.as_deref(),
4499        explain: cli.explain,
4500        explain_skipped: cli.explain_skipped,
4501        summary: cli.summary,
4502        group_by: cli.group_by,
4503        performance: cli.performance,
4504    })
4505}
4506
4507struct AuditDispatchArgs {
4508    production_dead_code: bool,
4509    production_health: bool,
4510    production_dupes: bool,
4511    dead_code_baseline: Option<PathBuf>,
4512    health_baseline: Option<PathBuf>,
4513    dupes_baseline: Option<PathBuf>,
4514    max_crap: Option<f64>,
4515    coverage: Option<PathBuf>,
4516    coverage_root: Option<PathBuf>,
4517    no_css: bool,
4518    css_deep: bool,
4519    no_css_deep: bool,
4520    gate: Option<AuditGateArg>,
4521    runtime_coverage: Option<PathBuf>,
4522    min_invocations_hot: u64,
4523    gate_marker: Option<String>,
4524    brief: bool,
4525    max_decisions: usize,
4526    /// Emit the agent-contract walkthrough guide instead of the brief body.
4527    walkthrough_guide: bool,
4528    /// Post-validate an agent's judgment JSON from this path against the
4529    /// live graph.
4530    walkthrough_file: Option<PathBuf>,
4531    /// Render the existing walkthrough guide as a staged human/markdown tour.
4532    walkthrough: bool,
4533    /// Changed files to record as VIEWED before rendering the tour.
4534    mark_viewed: Vec<PathBuf>,
4535    /// Expand the Cleared panel (de-prioritized + viewed) in the tour.
4536    show_cleared: bool,
4537    /// Expand the de-prioritized units in the human focus map.
4538    show_deprioritized: bool,
4539}
4540
4541struct ResolvedAuditInputs {
4542    audit_cfg: fallow_config::AuditConfig,
4543    cache_dir: PathBuf,
4544    production: ProductionModes,
4545    dead_code_baseline: Option<PathBuf>,
4546    health_baseline: Option<PathBuf>,
4547    dupes_baseline: Option<PathBuf>,
4548    coverage: Option<PathBuf>,
4549}
4550
4551fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4552    let cli = dispatch.cli;
4553    let output = dispatch.output;
4554
4555    if cli.baseline.is_some() || cli.save_baseline.is_some() {
4556        return emit_error(
4557            "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>`)",
4558            2,
4559            output,
4560        );
4561    }
4562
4563    let inputs = match resolve_audit_inputs(dispatch, args) {
4564        Ok(inputs) => inputs,
4565        Err(code) => return code,
4566    };
4567
4568    run_resolved_audit(dispatch, args, &inputs)
4569}
4570
4571fn resolve_audit_inputs(
4572    dispatch: &DispatchContext<'_>,
4573    args: &AuditDispatchArgs,
4574) -> Result<ResolvedAuditInputs, ExitCode> {
4575    let cli = dispatch.cli;
4576    let root = dispatch.root;
4577    let output = dispatch.output;
4578    let config = load_config(
4579        root,
4580        &cli.config,
4581        LoadConfigArgs {
4582            output,
4583            no_cache: cli.no_cache,
4584            threads: dispatch.threads,
4585            production: cli.production,
4586            quiet: dispatch.quiet,
4587            allow_remote_extends: cli.allow_remote_extends,
4588        },
4589    )?;
4590    let cache_dir = config.cache_dir.clone();
4591    let audit_cfg = config.audit;
4592    let production = resolve_production_modes(
4593        cli,
4594        root,
4595        output,
4596        args.production_dead_code,
4597        args.production_health,
4598        args.production_dupes,
4599    )?;
4600    let resolved_dead_code_baseline = resolve_audit_baseline_path(
4601        root,
4602        args.dead_code_baseline.as_deref(),
4603        audit_cfg.dead_code_baseline.as_deref(),
4604    );
4605    let resolved_health_baseline = resolve_audit_baseline_path(
4606        root,
4607        args.health_baseline.as_deref(),
4608        audit_cfg.health_baseline.as_deref(),
4609    );
4610    let resolved_dupes_baseline = resolve_audit_baseline_path(
4611        root,
4612        args.dupes_baseline.as_deref(),
4613        audit_cfg.dupes_baseline.as_deref(),
4614    );
4615    let coverage = args
4616        .coverage
4617        .clone()
4618        .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4619
4620    Ok(ResolvedAuditInputs {
4621        audit_cfg,
4622        cache_dir,
4623        production,
4624        dead_code_baseline: resolved_dead_code_baseline,
4625        health_baseline: resolved_health_baseline,
4626        dupes_baseline: resolved_dupes_baseline,
4627        coverage,
4628    })
4629}
4630
4631fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4632    !args.no_css && config.css.unwrap_or(true)
4633}
4634
4635fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4636    audit_css_enabled(config, args)
4637        && !args.no_css_deep
4638        && (args.css_deep || config.css_deep.unwrap_or(true))
4639}
4640
4641fn run_resolved_audit(
4642    dispatch: &DispatchContext<'_>,
4643    args: &AuditDispatchArgs,
4644    inputs: &ResolvedAuditInputs,
4645) -> ExitCode {
4646    let cli = dispatch.cli;
4647    audit::run_audit(
4648        &audit::AuditOptions {
4649            root: dispatch.root,
4650            config_path: &cli.config,
4651            cache_dir: &inputs.cache_dir,
4652            output: dispatch.output,
4653            json_style: dispatch.json_style,
4654            no_cache: cli.no_cache,
4655            threads: dispatch.threads,
4656            quiet: dispatch.quiet,
4657            allow_remote_extends: cli.allow_remote_extends,
4658            changed_since: cli.changed_since.as_deref(),
4659            production: cli.production,
4660            production_dead_code: Some(inputs.production.dead_code),
4661            production_health: Some(inputs.production.health),
4662            production_dupes: Some(inputs.production.dupes),
4663            workspace: cli.workspace.as_deref(),
4664            changed_workspaces: cli.changed_workspaces.as_deref(),
4665            explain: cli.explain,
4666            explain_skipped: cli.explain_skipped,
4667            performance: cli.performance,
4668            group_by: cli.group_by,
4669            dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4670            health_baseline: inputs.health_baseline.as_deref(),
4671            dupes_baseline: inputs.dupes_baseline.as_deref(),
4672            max_crap: args.max_crap,
4673            coverage: inputs.coverage.as_deref(),
4674            coverage_root: args.coverage_root.as_deref(),
4675            gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4676            include_entry_exports: cli.include_entry_exports,
4677            // Styling analytics, including deep cross-file reachability, is on
4678            // by default in `fallow audit`; both layers remain verdict-neutral
4679            // unless a user escalates a styling rule to error.
4680            css: audit_css_enabled(&inputs.audit_cfg, args),
4681            css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4682            runtime_coverage: args.runtime_coverage.as_deref(),
4683            min_invocations_hot: args.min_invocations_hot,
4684            brief: args.brief,
4685            max_decisions: args.max_decisions,
4686            walkthrough_guide: args.walkthrough_guide,
4687            walkthrough: args.walkthrough,
4688            mark_viewed: &args.mark_viewed,
4689            show_cleared: args.show_cleared,
4690            walkthrough_file: args.walkthrough_file.as_deref(),
4691            show_deprioritized: args.show_deprioritized,
4692        },
4693        args.gate_marker.as_deref(),
4694    )
4695}
4696
4697/// Dispatch `fallow decision-surface`: the separable apex. Reuses the audit
4698/// input resolution in brief mode (changed-code scope) with all gating /
4699/// coverage / baseline knobs defaulted, then renders ONLY the decision surface.
4700fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4701    let args = decision_surface_audit_args(max_decisions);
4702    let inputs = match resolve_audit_inputs(dispatch, &args) {
4703        Ok(inputs) => inputs,
4704        Err(code) => return code,
4705    };
4706    audit::run_decision_surface(&decision_surface_audit_options(
4707        dispatch,
4708        &inputs,
4709        max_decisions,
4710    ))
4711}
4712
4713fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4714    AuditDispatchArgs {
4715        production_dead_code: false,
4716        production_health: false,
4717        production_dupes: false,
4718        dead_code_baseline: None,
4719        health_baseline: None,
4720        dupes_baseline: None,
4721        max_crap: None,
4722        coverage: None,
4723        coverage_root: None,
4724        no_css: true,
4725        css_deep: false,
4726        no_css_deep: false,
4727        gate: None,
4728        runtime_coverage: None,
4729        min_invocations_hot: 0,
4730        gate_marker: None,
4731        brief: true,
4732        max_decisions,
4733        walkthrough_guide: false,
4734        walkthrough_file: None,
4735        walkthrough: false,
4736        mark_viewed: Vec::new(),
4737        show_cleared: false,
4738        show_deprioritized: false,
4739    }
4740}
4741
4742fn decision_surface_audit_options<'a>(
4743    dispatch: &'a DispatchContext<'a>,
4744    inputs: &'a ResolvedAuditInputs,
4745    max_decisions: usize,
4746) -> audit::AuditOptions<'a> {
4747    let cli = dispatch.cli;
4748    audit::AuditOptions {
4749        root: dispatch.root,
4750        config_path: &cli.config,
4751        cache_dir: &inputs.cache_dir,
4752        output: dispatch.output,
4753        json_style: dispatch.json_style,
4754        no_cache: cli.no_cache,
4755        threads: dispatch.threads,
4756        quiet: dispatch.quiet,
4757        allow_remote_extends: cli.allow_remote_extends,
4758        changed_since: cli.changed_since.as_deref(),
4759        production: cli.production,
4760        production_dead_code: Some(inputs.production.dead_code),
4761        production_health: Some(inputs.production.health),
4762        production_dupes: Some(inputs.production.dupes),
4763        workspace: cli.workspace.as_deref(),
4764        changed_workspaces: cli.changed_workspaces.as_deref(),
4765        explain: cli.explain,
4766        explain_skipped: cli.explain_skipped,
4767        performance: cli.performance,
4768        group_by: cli.group_by,
4769        dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4770        health_baseline: inputs.health_baseline.as_deref(),
4771        dupes_baseline: inputs.dupes_baseline.as_deref(),
4772        max_crap: None,
4773        coverage: None,
4774        coverage_root: None,
4775        gate: inputs.audit_cfg.gate,
4776        include_entry_exports: cli.include_entry_exports,
4777        // Decision-surface (brief apex) does not render styling; keep it lean.
4778        css: false,
4779        css_deep: false,
4780        runtime_coverage: None,
4781        min_invocations_hot: 0,
4782        brief: true,
4783        max_decisions,
4784        walkthrough_guide: false,
4785        walkthrough: false,
4786        mark_viewed: &[],
4787        show_cleared: false,
4788        walkthrough_file: None,
4789        show_deprioritized: false,
4790    }
4791}
4792
4793struct HealthDispatchArgs<'a> {
4794    max_cyclomatic: Option<u16>,
4795    max_cognitive: Option<u16>,
4796    max_crap: Option<f64>,
4797    top: Option<usize>,
4798    sort: health::SortBy,
4799    complexity: bool,
4800    complexity_breakdown: bool,
4801    file_scores: bool,
4802    coverage_gaps: bool,
4803    hotspots: bool,
4804    ownership: bool,
4805    ownership_emails: Option<fallow_config::EmailMode>,
4806    targets: bool,
4807    css: bool,
4808    effort: Option<EffortFilter>,
4809    score: bool,
4810    min_score: Option<f64>,
4811    min_severity: Option<fallow_output::FindingSeverity>,
4812    report_only: bool,
4813    since: Option<&'a str>,
4814    min_commits: Option<u32>,
4815    save_snapshot: Option<&'a Option<String>>,
4816    trend: bool,
4817    coverage: Option<&'a std::path::Path>,
4818    coverage_root: Option<&'a std::path::Path>,
4819    runtime_coverage: Option<&'a std::path::Path>,
4820    min_invocations_hot: u64,
4821    min_observation_volume: Option<u32>,
4822    low_traffic_threshold: Option<f64>,
4823}
4824
4825struct ResolvedHealthCoverageInputs {
4826    coverage: Option<PathBuf>,
4827    coverage_root: Option<PathBuf>,
4828}
4829
4830fn resolve_health_coverage_inputs(
4831    dispatch: &DispatchContext<'_>,
4832    cli_coverage: Option<&std::path::Path>,
4833    cli_coverage_root: Option<&std::path::Path>,
4834) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4835    let env_coverage = path_from_env("FALLOW_COVERAGE");
4836    let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4837    let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4838    let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4839    let config_health = if needs_config_coverage || needs_config_coverage_root {
4840        Some(
4841            load_config(
4842                dispatch.root,
4843                &dispatch.cli.config,
4844                LoadConfigArgs {
4845                    output: dispatch.output,
4846                    no_cache: dispatch.cli.no_cache,
4847                    threads: dispatch.threads,
4848                    production: dispatch.cli.production,
4849                    quiet: dispatch.quiet,
4850                    allow_remote_extends: dispatch.cli.allow_remote_extends,
4851                },
4852            )?
4853            .health,
4854        )
4855    } else {
4856        None
4857    };
4858
4859    Ok(ResolvedHealthCoverageInputs {
4860        coverage: cli_coverage
4861            .map(std::path::Path::to_path_buf)
4862            .or(env_coverage)
4863            .or_else(|| {
4864                config_health
4865                    .as_ref()
4866                    .and_then(|health| health.coverage.clone())
4867            }),
4868        coverage_root: cli_coverage_root
4869            .map(std::path::Path::to_path_buf)
4870            .or(env_coverage_root)
4871            .or_else(|| {
4872                config_health
4873                    .as_ref()
4874                    .and_then(|health| health.coverage_root.clone())
4875            }),
4876    })
4877}
4878
4879fn path_from_env(name: &str) -> Option<PathBuf> {
4880    std::env::var_os(name)
4881        .filter(|value| !value.is_empty())
4882        .map(PathBuf::from)
4883}
4884
4885fn validate_health_report_only_gate(
4886    report_only: bool,
4887    min_score: Option<f64>,
4888    min_severity: Option<fallow_output::FindingSeverity>,
4889    output: fallow_config::OutputFormat,
4890) -> Result<(), ExitCode> {
4891    if report_only && (min_score.is_some() || min_severity.is_some()) {
4892        return Err(emit_error(
4893            "--report-only cannot be combined with --min-score or --min-severity. \
4894             --report-only always exits 0; drop it to gate on score/severity, or \
4895             drop the gate flags to stay advisory.",
4896            2,
4897            output,
4898        ));
4899    }
4900
4901    Ok(())
4902}
4903
4904fn resolve_runtime_coverage_options(
4905    runtime_coverage: Option<&std::path::Path>,
4906    min_invocations_hot: u64,
4907    min_observation_volume: Option<u32>,
4908    low_traffic_threshold: Option<f64>,
4909    output: fallow_config::OutputFormat,
4910) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4911    let Some(path) = runtime_coverage else {
4912        return Ok(None);
4913    };
4914
4915    health::coverage::prepare_options(
4916        path,
4917        min_invocations_hot,
4918        min_observation_volume,
4919        low_traffic_threshold,
4920        output,
4921    )
4922    .map(Some)
4923}
4924
4925fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4926    let cli = dispatch.cli;
4927    let root = dispatch.root;
4928    let (output, _quiet, _fail_on_issues) =
4929        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4930    if let Err(code) = validate_health_report_only_gate(
4931        args.report_only,
4932        args.min_score,
4933        args.min_severity,
4934        output,
4935    ) {
4936        return code;
4937    }
4938    let runtime_coverage = match resolve_runtime_coverage_options(
4939        args.runtime_coverage,
4940        args.min_invocations_hot,
4941        args.min_observation_volume,
4942        args.low_traffic_threshold,
4943        output,
4944    ) {
4945        Ok(options) => options,
4946        Err(code) => return code,
4947    };
4948    let production = match resolve_production_modes(cli, root, output, false, false, false) {
4949        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4950        Err(code) => return code,
4951    };
4952    let coverage_inputs =
4953        match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4954            Ok(inputs) => inputs,
4955            Err(code) => return code,
4956        };
4957    let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4958    run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4959}
4960
4961fn derive_health_dispatch_run<'a>(
4962    args: &'a HealthDispatchArgs<'a>,
4963    output: fallow_config::OutputFormat,
4964    coverage_inputs: &'a ResolvedHealthCoverageInputs,
4965    runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4966) -> fallow_engine::health::HealthRunOptions<'a> {
4967    fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4968        output,
4969        thresholds: health_threshold_overrides(args),
4970        top: args.top,
4971        sort: args.sort.clone().into(),
4972        complexity: args.complexity,
4973        file_scores: args.file_scores,
4974        coverage_gaps: args.coverage_gaps,
4975        hotspots: args.hotspots,
4976        ownership: args.ownership,
4977        ownership_emails: args.ownership_emails,
4978        targets: args.targets,
4979        css: args.css,
4980        effort: args.effort.map(EffortFilter::to_estimate),
4981        score: args.score,
4982        gates: health_gate_options(args),
4983        snapshot_requested: args.save_snapshot.is_some(),
4984        trend: args.trend,
4985        since: args.since,
4986        min_commits: args.min_commits,
4987        coverage_inputs: health_coverage_inputs(coverage_inputs),
4988        runtime_coverage,
4989    })
4990}
4991
4992fn health_threshold_overrides(
4993    args: &HealthDispatchArgs<'_>,
4994) -> fallow_engine::health::HealthThresholdOverrides {
4995    fallow_engine::health::HealthThresholdOverrides {
4996        max_cyclomatic: args.max_cyclomatic,
4997        max_cognitive: args.max_cognitive,
4998        max_crap: args.max_crap,
4999    }
5000}
5001
5002fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5003    fallow_engine::health::HealthGateOptions {
5004        min_score: args.min_score,
5005        min_severity: args.min_severity,
5006        report_only: args.report_only,
5007    }
5008}
5009
5010fn health_coverage_inputs(
5011    coverage_inputs: &ResolvedHealthCoverageInputs,
5012) -> fallow_engine::health::HealthCoverageInputs<'_> {
5013    fallow_engine::health::HealthCoverageInputs {
5014        coverage: coverage_inputs.coverage.as_deref(),
5015        coverage_root: coverage_inputs.coverage_root.as_deref(),
5016    }
5017}
5018
5019/// Resolved inputs threaded from `dispatch_health` into the `HealthOptions`
5020/// builder. Owns the normalized engine run contract and resolved production
5021/// mode.
5022struct ResolvedHealthDispatch<'a> {
5023    run: fallow_engine::health::HealthRunOptions<'a>,
5024    production: bool,
5025}
5026
5027/// Build `HealthOptions` from the parsed args plus the resolved dispatch inputs,
5028/// then run the health analysis.
5029fn run_health_dispatch(
5030    dispatch: &DispatchContext<'_>,
5031    args: &HealthDispatchArgs<'_>,
5032    resolved: ResolvedHealthDispatch<'_>,
5033) -> ExitCode {
5034    let cli = dispatch.cli;
5035    let (output, quiet, _fail_on_issues) =
5036        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5037    let run = resolved.run;
5038    let sections = run.sections;
5039    let production = resolved.production;
5040    health::run_health(
5041        &HealthOptions {
5042            root: dispatch.root,
5043            config_path: &cli.config,
5044            output,
5045            no_cache: cli.no_cache,
5046            threads: dispatch.threads,
5047            quiet,
5048            thresholds: run.thresholds,
5049            top: run.top,
5050            sort: run.sort,
5051            production,
5052            production_override: Some(production),
5053            allow_remote_extends: cli.allow_remote_extends,
5054            changed_since: cli.changed_since.as_deref(),
5055            diff_index: None,
5056            use_shared_diff_index: true,
5057            workspace: cli.workspace.as_deref(),
5058            changed_workspaces: cli.changed_workspaces.as_deref(),
5059            baseline: cli.baseline.as_deref(),
5060            save_baseline: cli.save_baseline.as_deref(),
5061            complexity: sections.complexity,
5062            file_scores: sections.file_scores,
5063            coverage_gaps: sections.coverage_gaps,
5064            config_activates_coverage_gaps: !sections.any_section,
5065            hotspots: sections.hotspots,
5066            ownership: run.ownership,
5067            ownership_emails: run.ownership_emails,
5068            targets: sections.targets,
5069            css: sections.css,
5070            css_deep: false,
5071            force_full: sections.force_full,
5072            score_only_output: sections.score_only_output,
5073            enforce_coverage_gap_gate: true,
5074            effort: run.effort,
5075            score: sections.score,
5076            gates: run.gates,
5077            since: run.since,
5078            min_commits: run.min_commits,
5079            explain: cli.explain,
5080            summary: cli.summary,
5081            save_snapshot: args
5082                .save_snapshot
5083                .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5084            trend: args.trend,
5085            coverage_inputs: run.coverage_inputs,
5086            performance: cli.performance,
5087            runtime_coverage: run.runtime_coverage,
5088            churn_file: cli.churn_file.as_deref(),
5089            complexity_breakdown: args.complexity_breakdown,
5090            group_by: cli.group_by.map(Into::into),
5091        },
5092        dispatch.json_style,
5093    )
5094}
5095
5096#[cfg(test)]
5097mod tests {
5098    use super::*;
5099
5100    /// Validates that the CLI definition has no flag name collisions, missing
5101    /// fields, or other structural errors. Catches issues like a global alias
5102    /// `--base` colliding with a subcommand's `--base` flag.
5103    #[test]
5104    fn cli_definition_has_no_flag_collisions() {
5105        use clap::CommandFactory;
5106        Cli::command().debug_assert();
5107    }
5108
5109    #[test]
5110    fn regression_baseline_help_explains_the_default_destination() {
5111        use clap::CommandFactory;
5112        let help = Cli::command().render_long_help().to_string();
5113
5114        assert!(help.contains("Omit PATH to update regression.baseline"));
5115        assert!(help.contains("discovered fallow config"));
5116        assert!(help.contains("create .fallowrc.json when none exists"));
5117    }
5118
5119    /// The root `--help` cheat sheet is a static const that cannot call the
5120    /// shared renderer, so this test is the only guard that it stays in sync
5121    /// with `TASK_MATRIX`. Every row's command string must appear verbatim.
5122    #[test]
5123    fn after_help_lists_every_task_matrix_command() {
5124        for row in crate::task_matrix::TASK_MATRIX {
5125            assert!(
5126                TOP_LEVEL_AFTER_HELP.contains(row.command),
5127                "root --help cheat sheet is missing task-matrix command '{}'; \
5128                 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5129                row.command
5130            );
5131        }
5132    }
5133
5134    /// The high-value and coarse admin commands each get a distinct telemetry
5135    /// workflow instead of the `Unknown` catch-all, so command families stay
5136    /// answerable without uploading raw command lines.
5137    #[test]
5138    fn high_value_commands_route_to_distinct_workflows() {
5139        use clap::Parser;
5140        use fallow_config::OutputFormat;
5141
5142        let distinct = [
5143            (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5144            (vec!["fallow", "security"], telemetry::Workflow::Security),
5145            (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5146            (
5147                vec!["fallow", "explain", "unused-exports"],
5148                telemetry::Workflow::Explain,
5149            ),
5150            (
5151                vec!["fallow", "watch"],
5152                telemetry::Workflow::CodeQualityReview,
5153            ),
5154            (
5155                vec!["fallow", "list"],
5156                telemetry::Workflow::ProjectInventory,
5157            ),
5158            (
5159                vec!["fallow", "workspaces"],
5160                telemetry::Workflow::ProjectInventory,
5161            ),
5162            (
5163                vec!["fallow", "schema"],
5164                telemetry::Workflow::ProjectInventory,
5165            ),
5166            (vec!["fallow", "init"], telemetry::Workflow::Setup),
5167            (
5168                vec!["fallow", "hooks", "install", "--target", "git"],
5169                telemetry::Workflow::Setup,
5170            ),
5171            (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5172            (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5173            (
5174                vec!["fallow", "rule-pack-schema"],
5175                telemetry::Workflow::Setup,
5176            ),
5177            (vec!["fallow", "config"], telemetry::Workflow::Setup),
5178            (
5179                vec!["fallow", "ci-template", "gitlab"],
5180                telemetry::Workflow::Setup,
5181            ),
5182            (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5183            (
5184                vec!["fallow", "telemetry", "status"],
5185                telemetry::Workflow::Setup,
5186            ),
5187            (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5188            (
5189                vec!["fallow", "audit-cache", "remove", "--root", "."],
5190                telemetry::Workflow::Setup,
5191            ),
5192            (
5193                vec!["fallow", "license", "status"],
5194                telemetry::Workflow::License,
5195            ),
5196        ];
5197        for (argv, expected) in distinct {
5198            let cli = Cli::try_parse_from(&argv).expect("argv parses");
5199            assert_eq!(
5200                telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5201                expected,
5202                "{argv:?} should map to {expected:?}"
5203            );
5204        }
5205    }
5206
5207    /// `-v`, `-V`, and `--version` must all trigger clap's Version action so
5208    /// the version prints regardless of which spelling the user reaches for
5209    /// (issue #916). clap surfaces a Version action from `try_get_matches_from`
5210    /// as the `DisplayVersion` error kind.
5211    #[test]
5212    fn version_flag_accepts_lower_v_upper_v_and_long() {
5213        use clap::CommandFactory;
5214        for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5215            let err = Cli::command()
5216                .try_get_matches_from(argv)
5217                .expect_err("version flag should short-circuit parsing");
5218            assert_eq!(
5219                err.kind(),
5220                clap::error::ErrorKind::DisplayVersion,
5221                "{argv:?} should trigger the Version action"
5222            );
5223        }
5224    }
5225
5226    /// Guard against deferred-work wording leaking into clap-rendered help.
5227    /// `stub`, `placeholder`, and `not yet` framings tell users the feature
5228    /// is broken or pending; they belong in tracked issues, not in `--help`.
5229    /// Walk every (sub)command and assert each rendered long-help is clean.
5230    #[test]
5231    fn cli_help_text_contains_no_implementation_status_wording() {
5232        use clap::CommandFactory;
5233        let mut root = Cli::command();
5234        let mut violations: Vec<(String, String)> = Vec::new();
5235        visit_help(&mut root, "fallow", &mut violations);
5236        assert!(
5237            violations.is_empty(),
5238            "found implementation-status wording in --help output:\n{}",
5239            violations
5240                .iter()
5241                .map(|(cmd, line)| format!("  {cmd}: {line}"))
5242                .collect::<Vec<_>>()
5243                .join("\n")
5244        );
5245    }
5246
5247    #[test]
5248    fn top_level_help_groups_commands_by_workflow() {
5249        use clap::CommandFactory;
5250        let help = Cli::command().render_long_help().to_string();
5251        let expected_order = [
5252            "Analysis:",
5253            "  dead-code",
5254            "  dupes",
5255            "  health",
5256            "  flags",
5257            "  security",
5258            "  audit",
5259            "Workflow:",
5260            "  watch",
5261            "  fix",
5262            "Project inspection:",
5263            "  list",
5264            "  workspaces",
5265            "  explain",
5266            "  impact",
5267            "  viz",
5268            "Setup and configuration:",
5269            "  init",
5270            "  recommend",
5271            "  migrate",
5272            "  config",
5273            "  config-schema",
5274            "  plugin-schema",
5275            "  plugin-check",
5276            "  rule-pack-schema",
5277            "Automation and CI:",
5278            "  ci",
5279            "  ci-template",
5280            "  hooks",
5281            "  setup-hooks",
5282            "Runtime coverage:",
5283            "  coverage",
5284            "  license",
5285            "Reference:",
5286            "  schema",
5287            "  help",
5288            "Options:",
5289        ];
5290        let mut cursor = 0;
5291        for needle in expected_order {
5292            let Some(offset) = help[cursor..].find(needle) else {
5293                panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5294            };
5295            cursor += offset + needle.len();
5296        }
5297    }
5298
5299    #[test]
5300    fn security_help_hides_globals_rejected_by_security_validator() {
5301        let help = render_security_help(SecurityHelpTarget::Parent);
5302
5303        for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5304            assert!(
5305                !help_contains_long_flag(&help, long),
5306                "security help must hide unsupported --{long}:\n{help}"
5307            );
5308        }
5309
5310        for long in [
5311            "root",
5312            "config",
5313            "format",
5314            "quiet",
5315            "no-cache",
5316            "threads",
5317            "changed-since",
5318            "diff-file",
5319            "diff-stdin",
5320            "workspace",
5321            "changed-workspaces",
5322            "ci",
5323            "fail-on-issues",
5324            "sarif-file",
5325            "summary",
5326            "output-file",
5327            "max-file-size",
5328            "explain",
5329            "surface",
5330        ] {
5331            assert!(
5332                help_contains_long_flag(&help, long),
5333                "security help must keep supported --{long}:\n{help}"
5334            );
5335        }
5336    }
5337
5338    #[test]
5339    fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5340        assert_eq!(
5341            security_help_target(["security", "--help"]),
5342            Some(SecurityHelpTarget::Parent)
5343        );
5344        assert_eq!(
5345            security_help_target(["security", "-h"]),
5346            Some(SecurityHelpTarget::Parent)
5347        );
5348        assert_eq!(
5349            security_help_target(["--format", "json", "security", "--help"]),
5350            Some(SecurityHelpTarget::Parent)
5351        );
5352        assert_eq!(
5353            security_help_target(["help", "security"]),
5354            Some(SecurityHelpTarget::Parent)
5355        );
5356        assert_eq!(
5357            security_help_target(["security", "survivors", "--help"]),
5358            Some(SecurityHelpTarget::Survivors)
5359        );
5360        assert_eq!(
5361            security_help_target(["security", "survivors", "-h"]),
5362            Some(SecurityHelpTarget::Survivors)
5363        );
5364        assert_eq!(
5365            security_help_target(["help", "security", "survivors"]),
5366            Some(SecurityHelpTarget::Survivors)
5367        );
5368        assert_eq!(
5369            security_help_target(["security", "blind-spots", "--help"]),
5370            Some(SecurityHelpTarget::BlindSpots)
5371        );
5372        assert_eq!(
5373            security_help_target(["help", "security", "blind-spots"]),
5374            Some(SecurityHelpTarget::BlindSpots)
5375        );
5376        assert_eq!(security_help_target(["health", "--help"]), None);
5377        assert_eq!(security_help_target(["help", "health"]), None);
5378    }
5379
5380    #[test]
5381    fn security_unsupported_global_validator_matches_hidden_help_contract() {
5382        for (argv, expected) in [
5383            (vec!["fallow", "security", "--performance"], "--performance"),
5384            (
5385                vec!["fallow", "security", "--baseline", "base.json"],
5386                "--baseline",
5387            ),
5388            (
5389                vec!["fallow", "security", "--dupes-mode", "weak"],
5390                "--dupes-mode",
5391            ),
5392        ] {
5393            let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5394            assert_eq!(unsupported_security_global(&cli), Some(expected));
5395        }
5396
5397        let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5398            .expect("security --explain parses");
5399        assert_eq!(unsupported_security_global(&explain), None);
5400    }
5401
5402    #[test]
5403    fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5404        use clap::CommandFactory;
5405
5406        let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5407            .get_arguments()
5408            .filter(|arg| arg.is_global_set())
5409            .filter_map(|arg| arg.get_long().map(str::to_owned))
5410            .filter(|name| {
5411                matches!(
5412                    name.as_str(),
5413                    "root"
5414                        | "config"
5415                        | "allow-remote-extends"
5416                        | "no-cache"
5417                        | "threads"
5418                        | "changed-since"
5419                        | "diff-file"
5420                        | "production"
5421                        | "workspace"
5422                        | "changed-workspaces"
5423                        | "explain"
5424                )
5425            })
5426            .collect();
5427        let programmatic_flags: std::collections::BTreeSet<String> =
5428            fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5429                .iter()
5430                .map(|flag| (*flag).to_owned())
5431                .collect();
5432
5433        assert_eq!(programmatic_flags, cli_flags);
5434    }
5435
5436    #[test]
5437    fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5438        use clap::CommandFactory;
5439
5440        let cli = Cli::command();
5441        let dead_code = cli
5442            .get_subcommands()
5443            .find(|command| command.get_name() == "dead-code")
5444            .expect("dead-code subcommand is registered");
5445        let cli_flags: std::collections::BTreeSet<String> = dead_code
5446            .get_arguments()
5447            .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5448            .collect();
5449
5450        for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5451            assert!(
5452                cli_flags.contains(*flag),
5453                "registry filter flag {flag} is missing from dead-code clap args"
5454            );
5455        }
5456    }
5457
5458    fn help_contains_long_flag(help: &str, long: &str) -> bool {
5459        let flag = format!("--{long}");
5460        help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5461            .any(|token| token == flag)
5462    }
5463
5464    fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5465        let help = cmd.render_long_help().to_string();
5466        for line in scan_forbidden(&help) {
5467            violations.push((path.to_owned(), line));
5468        }
5469        let names: Vec<String> = cmd
5470            .get_subcommands()
5471            .map(|sub| sub.get_name().to_owned())
5472            .collect();
5473        for name in names {
5474            if name == "help" {
5475                continue;
5476            }
5477            if let Some(sub) = cmd.find_subcommand_mut(&name) {
5478                let sub_path = format!("{path} {name}");
5479                visit_help(sub, &sub_path, violations);
5480            }
5481        }
5482    }
5483
5484    fn scan_forbidden(s: &str) -> Vec<String> {
5485        let lower = s.to_ascii_lowercase();
5486        let mut out = Vec::new();
5487        for word in ["stub", "placeholder"] {
5488            if let Some(idx) = find_whole_word(&lower, word) {
5489                out.push(extract_line(s, idx));
5490            }
5491        }
5492        if let Some(idx) = lower.find("not yet") {
5493            out.push(extract_line(s, idx));
5494        }
5495        out
5496    }
5497
5498    fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5499        let bytes = haystack.as_bytes();
5500        let mut start = 0;
5501        while let Some(rel) = haystack[start..].find(word) {
5502            let abs = start + rel;
5503            let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5504            let after_idx = abs + word.len();
5505            let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5506            if before_ok && after_ok {
5507                return Some(abs);
5508            }
5509            start = abs + word.len();
5510        }
5511        None
5512    }
5513
5514    fn extract_line(s: &str, byte_idx: usize) -> String {
5515        let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5516        let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5517        s[line_start..line_end].trim().to_owned()
5518    }
5519
5520    #[test]
5521    fn emit_error_returns_given_exit_code() {
5522        let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5523        assert_eq!(code, ExitCode::from(2));
5524    }
5525
5526    fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5527        TelemetryRun {
5528            workflow: telemetry::Workflow::Health,
5529            output: fallow_config::OutputFormat::Json,
5530            quiet: true,
5531            start: std::time::Instant::now(),
5532            context: telemetry::WorkflowContext {
5533                run_scope: telemetry::RunScope::FullProject,
5534                config_shape: telemetry::ConfigShape::Default,
5535                output_destination: telemetry::OutputDestination::Stdout,
5536                analysis_mode: mode,
5537            },
5538        }
5539    }
5540
5541    #[test]
5542    fn fallback_failure_reason_skips_success_and_findings() {
5543        let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5544
5545        assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5546        assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5547    }
5548
5549    #[test]
5550    fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5551        let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5552        let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5553
5554        assert_eq!(
5555            fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5556            Some(telemetry::FailureReason::Network),
5557        );
5558        assert_eq!(
5559            fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5560            Some(telemetry::FailureReason::Auth),
5561        );
5562        assert_eq!(
5563            fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5564            Some(telemetry::FailureReason::Auth),
5565        );
5566        assert_eq!(
5567            fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5568            Some(telemetry::FailureReason::Analysis),
5569        );
5570    }
5571
5572    #[test]
5573    fn bare_coverage_flags_parse_without_subcommand() {
5574        let cli = Cli::try_parse_from([
5575            "fallow",
5576            "--coverage",
5577            "coverage/coverage-final.json",
5578            "--coverage-root",
5579            "/ci/workspace",
5580        ])
5581        .expect("bare combined coverage flags should parse");
5582        assert!(cli.command.is_none());
5583        assert_eq!(
5584            cli.coverage.as_deref(),
5585            Some(std::path::Path::new("coverage/coverage-final.json"))
5586        );
5587        assert_eq!(
5588            cli.coverage_root.as_deref(),
5589            Some(std::path::Path::new("/ci/workspace"))
5590        );
5591    }
5592
5593    #[test]
5594    fn bare_coverage_before_subcommand_is_detectable() {
5595        let cli = Cli::try_parse_from([
5596            "fallow",
5597            "--coverage",
5598            "coverage/coverage-final.json",
5599            "dead-code",
5600        ])
5601        .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5602        assert!(cli.command.is_some());
5603        assert!(cli_has_bare_coverage_input(&cli));
5604        let message = bare_coverage_subcommand_error_message();
5605        assert!(message.contains("bare combined-mode flags"));
5606        assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5607    }
5608
5609    #[test]
5610    fn subcommand_coverage_flag_keeps_regular_clap_error() {
5611        let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5612            panic!("dead-code --coverage should fail to parse");
5613        };
5614        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5615    }
5616
5617    #[test]
5618    fn format_parsing_covers_all_variants() {
5619        assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5620        assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5621        assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5622        assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5623        assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5624        assert!(matches!(
5625            parse_format_arg("markdown"),
5626            Some(Format::Markdown)
5627        ));
5628        assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5629        assert!(matches!(
5630            parse_format_arg("codeclimate"),
5631            Some(Format::CodeClimate)
5632        ));
5633        assert!(matches!(
5634            parse_format_arg("gitlab-codequality"),
5635            Some(Format::CodeClimate)
5636        ));
5637        assert!(matches!(
5638            parse_format_arg("gitlab-code-quality"),
5639            Some(Format::CodeClimate)
5640        ));
5641        assert!(matches!(
5642            parse_format_arg("pr-comment-github"),
5643            Some(Format::PrCommentGithub)
5644        ));
5645        assert!(matches!(
5646            parse_format_arg("pr-comment-gitlab"),
5647            Some(Format::PrCommentGitlab)
5648        ));
5649        assert!(matches!(
5650            parse_format_arg("review-github"),
5651            Some(Format::ReviewGithub)
5652        ));
5653        assert!(matches!(
5654            parse_format_arg("review-gitlab"),
5655            Some(Format::ReviewGitlab)
5656        ));
5657        assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5658        assert!(parse_format_arg("xml").is_none());
5659        assert!(parse_format_arg("").is_none());
5660    }
5661
5662    #[test]
5663    fn quiet_parsing_logic() {
5664        let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5665        assert!(parse("1"));
5666        assert!(parse("true"));
5667        assert!(parse("TRUE"));
5668        assert!(parse("True"));
5669        assert!(!parse("0"));
5670        assert!(!parse("false"));
5671        assert!(!parse("yes"));
5672    }
5673
5674    #[test]
5675    fn tracing_filter_defaults_to_warn_without_env() {
5676        assert_eq!(build_tracing_filter(None).to_string(), "warn");
5677    }
5678
5679    #[test]
5680    fn tracing_filter_respects_explicit_env_directives() {
5681        assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5682    }
5683
5684    #[test]
5685    fn tracing_filter_treats_empty_env_as_off() {
5686        assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5687        assert_eq!(build_tracing_filter(Some("   ")).to_string(), "off");
5688    }
5689}