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