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