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