Skip to main content

fallow_cli/
lib.rs

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