Skip to main content

fallow_cli/
lib.rs

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