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