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