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