#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI binary produces intentional terminal output"
)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests use unwrap and expect to keep fixture setup concise"
)
)]
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod api;
mod audit;
mod audit_brief;
mod audit_decision_surface;
mod audit_focus;
mod audit_walkthrough;
mod base_worktree;
mod walkthrough_state;
use fallow_engine::baseline;
mod cache_notice;
mod check;
mod ci;
mod ci_template;
mod cli_format;
mod cli_hooks;
mod cli_impact;
mod cli_production;
mod cli_startup;
use fallow_engine::codeowners;
mod combined;
mod config;
mod coverage;
mod dupes;
mod explain;
mod fix;
mod flags;
mod guard;
mod health;
mod impact;
mod init;
mod inspect;
mod license;
mod list;
mod migrate;
#[cfg(test)]
mod output_envelope;
mod output_runtime;
mod path_util;
mod rayon_pool;
mod regression;
mod report;
mod rule_pack;
mod runtime_support;
mod schema;
mod security;
mod security_help;
mod setup_hooks;
mod signal;
mod task_matrix;
mod telemetry;
mod trace_chain;
mod update_check;
use fallow_engine::validate;
use fallow_engine::vital_signs;
mod cli_telemetry;
mod watch;
use check::{CheckOptions, IssueFilters, TraceOptions};
mod error;
#[cfg(test)]
use cli_format::parse_format_arg;
use cli_format::{Format, FormatConfig, apply_ci_defaults};
use cli_hooks::{HooksCli, run_hooks_command};
use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
use cli_production::{ProductionModes, resolve_production_modes};
#[cfg(test)]
use cli_startup::build_tracing_filter;
use cli_startup::{
bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
run_pre_dispatch_checks, setup_tracing, validate_inputs,
};
#[cfg(test)]
use cli_telemetry::TelemetryRun;
#[cfg(test)]
use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
use cli_telemetry::{record_run_epilogue, start_telemetry_run};
use dupes::{DupesMode, DupesOptions};
use error::emit_error;
use health::{HealthOptions, SortBy};
use list::ListOptions;
pub use runtime_support::{AnalysisKind, GroupBy};
pub(crate) use runtime_support::{
ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
load_config_for_analysis,
};
#[cfg(test)]
use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
use security_help::{render_security_help, security_help_target};
const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
const TOP_LEVEL_HELP_TEMPLATE: &str =
"{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
const TOP_LEVEL_AFTER_HELP: &str = "\
Analysis:
dead-code Analyze unused code, dependency hygiene, and architecture cycles
dupes Find copy-paste and structural code duplication
health Analyze complexity, maintainability, hotspots, and coverage gaps
flags Detect feature flag usage patterns
security Surface local security candidates for agent verification (opt-in)
audit Review changed files for dead code, complexity, duplication, and styling
Workflow:
watch Re-run analysis as files change
fix Auto-fix safe unused-code findings
Project inspection:
list List discovered files, entry points, plugins, boundaries, and workspaces
inspect Inspect one file or exported symbol as a bundled evidence query
workspaces Show monorepo workspace discovery diagnostics
explain Explain one issue type without running analysis
impact Show what fallow has done for you (opt-in, local-only)
Setup and configuration:
init Create a fallow config, optionally with a Git hook
migrate Migrate knip, jscpd, or stylelint config to fallow
config Show the resolved config and loaded config file
config-schema Print the fallow config JSON Schema
plugin-schema Print the external plugin JSON Schema
rule-pack-schema Print the rule pack JSON Schema
Automation and CI:
ci Build PR/MR feedback envelopes
ci-template Print or vendor CI integration templates
hooks Install or remove fallow-managed Git and agent hooks
setup-hooks Legacy agent-hook installer
Runtime coverage:
coverage Set up or analyze runtime coverage data
license Manage the paid-feature license
telemetry Manage opt-in product telemetry
Reference:
schema Dump the CLI interface as machine-readable JSON
help Print this message or the help of a command
When no command is given, fallow runs dead-code + dupes + health together.
Use --only/--skip to select specific analyses.
When the agent is about to...
delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
commit or open a PR fallow audit --base <ref>
prioritize refactoring fallow health --hotspots --targets
ask who owns code fallow health --ownership
check untested-but-reachable code fallow health --coverage-gaps
consolidate duplication fallow dupes --trace dup:<fingerprint>
find feature flags fallow flags
check architecture rules before editing fallow guard <files>
surface security candidates fallow security
inspect a target before editing fallow inspect --file <path>
understand a finding fallow explain <issue-type>
scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
#[derive(Parser)]
#[command(
name = "fallow",
about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
version,
disable_version_flag = true,
help_template = TOP_LEVEL_HELP_TEMPLATE,
after_help = TOP_LEVEL_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(
short = 'v',
visible_short_alias = 'V',
long = "version",
action = clap::ArgAction::Version
)]
version: Option<bool>,
#[arg(short, long, global = true)]
root: Option<PathBuf>,
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[arg(
short,
long,
visible_alias = "output",
global = true,
default_value = "human"
)]
format: Format,
#[arg(short, long, global = true)]
quiet: bool,
#[arg(long, global = true)]
no_cache: bool,
#[arg(long, global = true)]
threads: Option<usize>,
#[arg(long, visible_alias = "base", global = true)]
changed_since: Option<String>,
#[arg(long = "diff-file", value_name = "PATH", global = true)]
diff_file: Option<PathBuf>,
#[arg(long = "diff-stdin", global = true)]
diff_stdin: bool,
#[arg(long = "churn-file", value_name = "PATH", global = true)]
churn_file: Option<PathBuf>,
#[arg(long = "max-file-size", value_name = "MB", global = true)]
max_file_size: Option<u32>,
#[arg(long, global = true)]
baseline: Option<PathBuf>,
#[arg(long, global = true, value_name = "RUN_ID", hide = true)]
parent_run: Option<String>,
#[arg(long, global = true)]
save_baseline: Option<PathBuf>,
#[arg(long, global = true)]
production: bool,
#[arg(long = "no-production", global = true, conflicts_with = "production")]
no_production: bool,
#[arg(long = "production-dead-code")]
production_dead_code: bool,
#[arg(long = "production-health")]
production_health: bool,
#[arg(long = "production-dupes")]
production_dupes: bool,
#[arg(short, long, global = true, value_delimiter = ',')]
workspace: Option<Vec<String>>,
#[arg(long, global = true, value_name = "REF")]
changed_workspaces: Option<String>,
#[arg(long, global = true)]
group_by: Option<GroupBy>,
#[arg(long, global = true)]
performance: bool,
#[arg(long, global = true)]
explain: bool,
#[arg(long, global = true)]
explain_skipped: bool,
#[arg(long, global = true)]
summary: bool,
#[arg(long, global = true)]
ci: bool,
#[arg(long, global = true)]
fail_on_issues: bool,
#[arg(long, global = true, value_name = "PATH")]
sarif_file: Option<PathBuf>,
#[arg(short = 'o', long, global = true, value_name = "PATH")]
output_file: Option<PathBuf>,
#[arg(long, global = true)]
fail_on_regression: bool,
#[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
tolerance: String,
#[arg(long, global = true, value_name = "PATH")]
regression_baseline: Option<PathBuf>,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
)]
#[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_regression_baseline: Option<Option<String>>,
#[arg(long, value_delimiter = ',')]
only: Vec<AnalysisKind>,
#[arg(long, value_delimiter = ',')]
skip: Vec<AnalysisKind>,
#[arg(long = "dupes-mode", global = true)]
dupes_mode: Option<DupesMode>,
#[arg(long = "dupes-threshold", global = true)]
dupes_threshold: Option<f64>,
#[arg(long = "dupes-min-tokens", global = true)]
dupes_min_tokens: Option<usize>,
#[arg(long = "dupes-min-lines", global = true)]
dupes_min_lines: Option<usize>,
#[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
dupes_min_occurrences: Option<usize>,
#[arg(long = "dupes-skip-local", global = true)]
dupes_skip_local: bool,
#[arg(long = "dupes-cross-language", global = true)]
dupes_cross_language: bool,
#[arg(long = "dupes-ignore-imports", global = true)]
dupes_ignore_imports: bool,
#[arg(
long = "dupes-no-ignore-imports",
global = true,
conflicts_with = "dupes_ignore_imports"
)]
dupes_no_ignore_imports: bool,
#[arg(long)]
score: bool,
#[arg(long)]
trend: bool,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
)]
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_snapshot: Option<Option<String>>,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long = "coverage-root", value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long, global = true)]
include_entry_exports: bool,
}
#[derive(Subcommand)]
enum Command {
#[command(name = "dead-code", alias = "check")]
Check {
#[arg(long)]
unused_files: bool,
#[arg(long)]
unused_exports: bool,
#[arg(long)]
unused_deps: bool,
#[arg(long)]
unused_types: bool,
#[arg(long)]
private_type_leaks: bool,
#[arg(long)]
unused_enum_members: bool,
#[arg(long)]
unused_class_members: bool,
#[arg(long)]
unused_store_members: bool,
#[arg(long)]
unprovided_injects: bool,
#[arg(long)]
unrendered_components: bool,
#[arg(long)]
unused_component_props: bool,
#[arg(long)]
unused_component_emits: bool,
#[arg(long)]
unused_component_inputs: bool,
#[arg(long)]
unused_component_outputs: bool,
#[arg(long)]
unused_svelte_events: bool,
#[arg(long)]
unused_server_actions: bool,
#[arg(long)]
unused_load_data_keys: bool,
#[arg(long)]
unresolved_imports: bool,
#[arg(long)]
unlisted_deps: bool,
#[arg(long)]
duplicate_exports: bool,
#[arg(long)]
circular_deps: bool,
#[arg(long)]
re_export_cycles: bool,
#[arg(long)]
boundary_violations: bool,
#[arg(long)]
policy_violations: bool,
#[arg(long)]
stale_suppressions: bool,
#[arg(long)]
unused_catalog_entries: bool,
#[arg(long)]
empty_catalog_groups: bool,
#[arg(long)]
unresolved_catalog_references: bool,
#[arg(long)]
unused_dependency_overrides: bool,
#[arg(long)]
misconfigured_dependency_overrides: bool,
#[arg(long)]
include_dupes: bool,
#[arg(long, value_name = "FILE:EXPORT")]
trace: Option<String>,
#[arg(long, value_name = "PATH")]
trace_file: Option<String>,
#[arg(long, value_name = "PACKAGE")]
trace_dependency: Option<String>,
#[arg(long, value_name = "PATH")]
impact_closure: Option<String>,
#[arg(long)]
top: Option<usize>,
#[arg(long, value_name = "PATH")]
file: Vec<std::path::PathBuf>,
},
Watch {
#[arg(long)]
no_clear: bool,
},
Inspect {
#[arg(
long,
value_name = "PATH",
conflicts_with = "symbol",
required_unless_present = "symbol"
)]
file: Option<String>,
#[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
symbol: Option<String>,
#[arg(long)]
symbol_chain: bool,
},
Trace {
#[arg(value_name = "FILE:SYMBOL")]
symbol: String,
#[arg(long)]
callers: bool,
#[arg(long)]
callees: bool,
#[arg(long, value_name = "N")]
depth: Option<u32>,
},
Fix {
#[arg(long)]
dry_run: bool,
#[arg(long, alias = "force")]
yes: bool,
#[arg(long)]
no_create_config: bool,
},
Init {
#[arg(long)]
toml: bool,
#[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
agents: bool,
#[arg(long)]
hooks: bool,
#[arg(long, requires = "hooks")]
branch: Option<String>,
#[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
decline: bool,
},
Hooks {
#[command(subcommand)]
subcommand: HooksCli,
},
Ci {
#[command(subcommand)]
subcommand: CiCli,
},
ConfigSchema,
PluginSchema,
RulePackSchema,
RulePack {
#[command(subcommand)]
subcommand: RulePackCli,
},
Guard {
#[arg(required = true, num_args = 1..)]
files: Vec<String>,
},
Config {
#[arg(long)]
path: bool,
},
List {
#[arg(long)]
entry_points: bool,
#[arg(long)]
files: bool,
#[arg(long)]
plugins: bool,
#[arg(long)]
boundaries: bool,
#[arg(long)]
workspaces: bool,
},
Workspaces,
Dupes {
#[arg(long)]
mode: Option<DupesMode>,
#[arg(long)]
min_tokens: Option<usize>,
#[arg(long)]
min_lines: Option<usize>,
#[arg(long, value_parser = parse_min_occurrences)]
min_occurrences: Option<usize>,
#[arg(long)]
threshold: Option<f64>,
#[arg(long)]
skip_local: bool,
#[arg(long)]
cross_language: bool,
#[arg(long)]
ignore_imports: bool,
#[arg(long, conflicts_with = "ignore_imports")]
no_ignore_imports: bool,
#[arg(long)]
top: Option<usize>,
#[arg(long, value_name = "FILE:LINE")]
trace: Option<String>,
},
Health {
#[arg(long)]
max_cyclomatic: Option<u16>,
#[arg(long)]
max_cognitive: Option<u16>,
#[arg(long)]
max_crap: Option<f64>,
#[arg(long)]
top: Option<usize>,
#[arg(long, default_value = "cyclomatic")]
sort: SortBy,
#[arg(long)]
complexity: bool,
#[arg(long)]
complexity_breakdown: bool,
#[arg(long)]
file_scores: bool,
#[arg(long)]
coverage_gaps: bool,
#[arg(long)]
hotspots: bool,
#[arg(long)]
ownership: bool,
#[arg(long, value_name = "MODE", value_enum)]
ownership_emails: Option<EmailModeArg>,
#[arg(long)]
targets: bool,
#[arg(long)]
css: bool,
#[arg(long, value_enum)]
effort: Option<EffortFilter>,
#[arg(long)]
score: bool,
#[arg(long, value_name = "N")]
min_score: Option<f64>,
#[arg(long, value_name = "LEVEL", value_enum)]
min_severity: Option<HealthSeverityCli>,
#[arg(long)]
report_only: bool,
#[arg(long, value_name = "DURATION")]
since: Option<String>,
#[arg(long, value_name = "N")]
min_commits: Option<u32>,
#[expect(
clippy::option_option,
reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
)]
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
save_snapshot: Option<Option<String>>,
#[arg(long)]
trend: bool,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
runtime_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "N")]
min_observation_volume: Option<u32>,
#[arg(long, value_name = "RATIO")]
low_traffic_threshold: Option<f64>,
},
Flags {
#[arg(long)]
top: Option<usize>,
},
Explain {
#[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
issue_type: Vec<String>,
},
#[command(visible_alias = "review")]
Audit {
#[arg(long = "production-dead-code")]
production_dead_code: bool,
#[arg(long = "production-health")]
production_health: bool,
#[arg(long = "production-dupes")]
production_dupes: bool,
#[arg(long)]
dead_code_baseline: Option<PathBuf>,
#[arg(long)]
health_baseline: Option<PathBuf>,
#[arg(long)]
dupes_baseline: Option<PathBuf>,
#[arg(long)]
max_crap: Option<f64>,
#[arg(long, value_name = "PATH")]
coverage: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
coverage_root: Option<PathBuf>,
#[arg(long = "no-css")]
no_css: bool,
#[arg(long)]
css_deep: bool,
#[arg(long = "no-css-deep")]
no_css_deep: bool,
#[arg(long, value_enum)]
gate: Option<AuditGateArg>,
#[arg(long, value_name = "PATH")]
runtime_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "MARKER", hide = true)]
gate_marker: Option<String>,
#[arg(long)]
brief: bool,
#[arg(
long,
value_name = "N",
default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
)]
max_decisions: usize,
#[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
walkthrough_guide: bool,
#[arg(long, value_name = "PATH")]
walkthrough_file: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
walkthrough: bool,
#[arg(long, value_name = "PATH")]
mark_viewed: Vec<PathBuf>,
#[arg(long)]
show_cleared: bool,
#[arg(long)]
show_deprioritized: bool,
},
DecisionSurface {
#[arg(
long,
value_name = "N",
default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
)]
max_decisions: usize,
},
Impact {
#[command(subcommand)]
subcommand: Option<ImpactCli>,
#[arg(long)]
all: bool,
#[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
sort: ImpactSortCli,
#[arg(long)]
limit: Option<usize>,
},
Security {
#[command(subcommand)]
subcommand: Option<SecuritySubcommand>,
#[arg(long, value_name = "PATH")]
runtime_coverage: Option<PathBuf>,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "PATH")]
file: Vec<std::path::PathBuf>,
#[arg(long, value_name = "MODE")]
gate: Option<security::SecurityGateArg>,
#[arg(long)]
surface: bool,
},
Schema,
CiTemplate {
#[command(subcommand)]
subcommand: CiTemplateCli,
},
Migrate {
#[arg(long, conflicts_with = "jsonc")]
toml: bool,
#[arg(long)]
jsonc: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "PATH")]
from: Option<PathBuf>,
},
License {
#[command(subcommand)]
subcommand: LicenseCli,
},
Telemetry {
#[command(subcommand)]
subcommand: TelemetryCli,
},
Coverage {
#[command(subcommand)]
subcommand: CoverageCli,
},
SetupHooks {
#[arg(long, value_enum)]
agent: Option<setup_hooks::HookAgentArg>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
user: bool,
#[arg(long)]
gitignore_claude: bool,
#[arg(long)]
uninstall: bool,
},
}
#[derive(Subcommand)]
enum SecuritySubcommand {
Survivors {
#[arg(long, value_name = "PATH")]
candidates: PathBuf,
#[arg(long, value_name = "PATH")]
verdicts: PathBuf,
#[arg(long)]
require_verdict_for_each_candidate: bool,
},
#[command(name = "blind-spots")]
BlindSpots {
#[arg(long, value_name = "PATH")]
file: Vec<PathBuf>,
},
}
#[derive(clap::Subcommand)]
enum LicenseCli {
Activate {
#[arg(value_name = "JWT")]
jwt: Option<String>,
#[arg(long, value_name = "PATH")]
from_file: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["jwt", "from_file"])]
stdin: bool,
#[arg(long, requires = "email")]
trial: bool,
#[arg(long, value_name = "ADDR")]
email: Option<String>,
},
Status,
Refresh,
Deactivate,
}
#[derive(Clone, Copy, clap::Subcommand)]
enum TelemetryCli {
Status,
Enable,
Disable,
Inspect {
#[arg(long)]
example: bool,
},
}
#[derive(clap::Subcommand)]
enum CiTemplateCli {
Gitlab {
#[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
vendor: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(clap::Subcommand)]
enum CoverageCli {
Setup {
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
json: bool,
},
Analyze {
#[arg(long, value_name = "PATH", conflicts_with = "cloud")]
runtime_coverage: Option<PathBuf>,
#[arg(long, visible_alias = "runtime-coverage-cloud")]
cloud: bool,
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "OWNER/REPO")]
repo: Option<String>,
#[arg(long, value_name = "ID")]
project_id: Option<String>,
#[arg(long, value_name = "DAYS", default_value_t = 30)]
coverage_period: u16,
#[arg(long, value_name = "ENV")]
environment: Option<String>,
#[arg(long, value_name = "SHA")]
commit_sha: Option<String>,
#[arg(long)]
production: bool,
#[arg(long, default_value_t = 100)]
min_invocations_hot: u64,
#[arg(long, value_name = "N")]
min_observation_volume: Option<u32>,
#[arg(long, value_name = "RATIO")]
low_traffic_threshold: Option<f64>,
#[arg(long)]
top: Option<usize>,
#[arg(long)]
blast_radius: bool,
#[arg(long)]
importance: bool,
},
UploadInventory {
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "PROJECT_ID")]
project_id: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long)]
allow_dirty: bool,
#[arg(long, value_name = "GLOB", num_args = 0..)]
exclude_paths: Vec<String>,
#[arg(long, value_name = "PREFIX")]
path_prefix: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
with_callers: bool,
#[arg(long)]
ignore_upload_errors: bool,
},
UploadSourceMaps {
#[arg(long, value_name = "PATH", default_value = "dist")]
dir: PathBuf,
#[arg(long, value_name = "GLOB", default_value = "**/*.map")]
include: String,
#[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
exclude: Vec<String>,
#[arg(long, value_name = "NAME")]
repo: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long, value_name = "URL")]
endpoint: Option<String>,
#[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
strip_path: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "N", default_value_t = 4)]
concurrency: usize,
#[arg(long)]
fail_fast: bool,
},
UploadStaticFindings {
#[arg(long, value_name = "KEY")]
api_key: Option<String>,
#[arg(long, value_name = "URL")]
api_endpoint: Option<String>,
#[arg(long, value_name = "PROJECT_ID")]
project_id: Option<String>,
#[arg(long, value_name = "SHA")]
git_sha: Option<String>,
#[arg(long)]
allow_dirty: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
ignore_upload_errors: bool,
},
}
#[derive(Subcommand)]
enum CiCli {
PlanPrComment {
#[arg(long)]
body: PathBuf,
#[arg(long)]
marker_id: String,
#[arg(long)]
clean: bool,
#[arg(long)]
existing_comment_id: Option<String>,
#[arg(long)]
existing_body: Option<PathBuf>,
},
PostPrComment {
#[arg(long, value_enum)]
provider: CiProviderArg,
#[arg(long)]
pr: Option<String>,
#[arg(long)]
mr: Option<String>,
#[arg(long)]
body: PathBuf,
#[arg(long)]
envelope: Option<PathBuf>,
#[arg(long)]
marker_id: String,
#[arg(long)]
clean: bool,
#[arg(long)]
repo: Option<String>,
#[arg(long = "project-id")]
project_id: Option<String>,
#[arg(long = "api-url")]
api_url: Option<String>,
#[arg(long)]
dry_run: bool,
},
PostReview {
#[arg(long, value_enum)]
provider: CiProviderArg,
#[arg(long)]
pr: Option<String>,
#[arg(long)]
mr: Option<String>,
#[arg(long)]
envelope: PathBuf,
#[arg(long)]
repo: Option<String>,
#[arg(long = "project-id")]
project_id: Option<String>,
#[arg(long = "api-url")]
api_url: Option<String>,
#[arg(long)]
dry_run: bool,
},
PostCheckRun {
#[arg(long, value_enum)]
provider: CiProviderArg,
#[arg(long)]
decision: PathBuf,
#[arg(long)]
repo: String,
#[arg(long = "head-sha")]
head_sha: String,
#[arg(long = "api-url")]
api_url: Option<String>,
#[arg(long = "split-gates")]
split_gates: bool,
#[arg(long)]
dry_run: bool,
},
ReconcileReview {
#[arg(long, value_enum)]
provider: CiProviderArg,
#[arg(long)]
pr: Option<String>,
#[arg(long)]
mr: Option<String>,
#[arg(long)]
envelope: PathBuf,
#[arg(long)]
repo: Option<String>,
#[arg(long = "project-id")]
project_id: Option<String>,
#[arg(long = "api-url")]
api_url: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
enum RulePackCli {
Init {
name: Option<String>,
#[arg(long, default_value = "starter")]
template: String,
#[arg(long, default_value = "rule-packs")]
dir: String,
#[arg(long)]
no_config: bool,
},
List,
Test {
pack: Option<PathBuf>,
},
Schema,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum CiProviderArg {
Github,
Gitlab,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EffortFilter {
Low,
Medium,
High,
}
impl EffortFilter {
const fn to_estimate(self) -> fallow_output::EffortEstimate {
match self {
Self::Low => fallow_output::EffortEstimate::Low,
Self::Medium => fallow_output::EffortEstimate::Medium,
Self::High => fallow_output::EffortEstimate::High,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum HealthSeverityCli {
Moderate,
High,
Critical,
}
impl HealthSeverityCli {
const fn to_health_severity(self) -> fallow_output::FindingSeverity {
match self {
Self::Moderate => fallow_output::FindingSeverity::Moderate,
Self::High => fallow_output::FindingSeverity::High,
Self::Critical => fallow_output::FindingSeverity::Critical,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EmailModeArg {
Raw,
Handle,
Anonymized,
#[value(hide = true)]
Hash,
}
impl EmailModeArg {
const fn to_config(self) -> fallow_config::EmailMode {
match self {
Self::Raw => fallow_config::EmailMode::Raw,
Self::Handle => fallow_config::EmailMode::Handle,
Self::Anonymized => fallow_config::EmailMode::Anonymized,
Self::Hash => fallow_config::EmailMode::Hash,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum AuditGateArg {
NewOnly,
All,
}
impl From<AuditGateArg> for fallow_config::AuditGate {
fn from(value: AuditGateArg) -> Self {
match value {
AuditGateArg::NewOnly => Self::NewOnly,
AuditGateArg::All => Self::All,
}
}
}
fn parse_min_occurrences(s: &str) -> Result<usize, String> {
let value: usize = s
.parse()
.map_err(|_| format!("`{s}` is not a non-negative integer"))?;
if value < 2 {
return Err(format!(
"must be at least 2 (got {value}); a single occurrence isn't a duplicate"
));
}
Ok(value)
}
fn resolve_audit_baseline_path(
root: &std::path::Path,
cli: Option<&std::path::Path>,
config: Option<&str>,
) -> Option<PathBuf> {
let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
config.map(|p| {
let path = PathBuf::from(p);
if path_util::is_absolute_path_any_platform(&path) {
path
} else {
root.join(path)
}
})
})?;
if path_util::is_absolute_path_any_platform(&path) {
Some(path)
} else {
Some(root.join(path))
}
}
fn emit_known_failure(
message: &str,
exit_code: u8,
output: fallow_config::OutputFormat,
reason: telemetry::FailureReason,
) -> ExitCode {
telemetry::note_failure_reason(reason);
emit_error(message, exit_code, output)
}
fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
if cli.baseline.is_some() {
Some("--baseline")
} else if cli.save_baseline.is_some() {
Some("--save-baseline")
} else if cli.production {
Some("--production")
} else if cli.no_production {
Some("--no-production")
} else if cli.group_by.is_some() {
Some("--group-by")
} else if cli.performance {
Some("--performance")
} else if cli.explain_skipped {
Some("--explain-skipped")
} else if cli.fail_on_regression {
Some("--fail-on-regression")
} else if cli.regression_baseline.is_some() {
Some("--regression-baseline")
} else if cli.save_regression_baseline.is_some() {
Some("--save-regression-baseline")
} else if cli.dupes_mode.is_some() {
Some("--dupes-mode")
} else if cli.dupes_threshold.is_some() {
Some("--dupes-threshold")
} else if cli.dupes_min_tokens.is_some() {
Some("--dupes-min-tokens")
} else if cli.dupes_min_lines.is_some() {
Some("--dupes-min-lines")
} else if cli.dupes_min_occurrences.is_some() {
Some("--dupes-min-occurrences")
} else if cli.dupes_skip_local {
Some("--dupes-skip-local")
} else if cli.dupes_cross_language {
Some("--dupes-cross-language")
} else if cli.dupes_ignore_imports {
Some("--dupes-ignore-imports")
} else if cli.dupes_no_ignore_imports {
Some("--dupes-no-ignore-imports")
} else if cli.include_entry_exports {
Some("--include-entry-exports")
} else {
None
}
}
struct DispatchContext<'a> {
cli: &'a Cli,
root: &'a std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
threads: usize,
tolerance: regression::Tolerance,
save_regression_file: Option<&'a std::path::PathBuf>,
save_to_config: bool,
}
impl DispatchContext<'_> {
fn ci_defaults(&self) -> (fallow_config::OutputFormat, bool, bool) {
apply_ci_defaults(
self.cli.ci,
self.cli.fail_on_issues,
self.output,
self.quiet,
self.cli_format_was_explicit,
)
}
fn production_modes(
&self,
dead_code: bool,
health: bool,
dupes: bool,
) -> Result<ProductionModes, ExitCode> {
resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
}
fn production_for(
&self,
analysis: fallow_config::ProductionAnalysis,
) -> Result<bool, ExitCode> {
self.production_modes(false, false, false)
.map(|modes| modes.for_analysis(analysis))
}
fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
regression::RegressionOpts {
fail_on_regression: self.cli.fail_on_regression,
tolerance: self.tolerance,
regression_baseline_file: self.cli.regression_baseline.as_deref(),
save_target: if let Some(path) = self.save_regression_file {
regression::SaveRegressionTarget::File(path)
} else if self.save_to_config {
regression::SaveRegressionTarget::Config
} else {
regression::SaveRegressionTarget::None
},
scoped,
quiet: self.quiet,
output: self.output,
}
}
}
#[cfg(unix)]
fn signal_test_helper() -> ExitCode {
use std::io::Write as _;
use std::process::Command;
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
signal::set_graceful_mode();
}
let mut command = Command::new("sleep");
command.arg("30");
let child = match signal::ScopedChild::spawn(&mut command) {
Ok(c) => c,
Err(err) => {
let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
return ExitCode::from(2);
}
};
let pid = child.id();
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{pid}");
let _ = lock.flush();
drop(lock);
let _ = child.wait_with_output();
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
return ExitCode::SUCCESS;
}
std::thread::sleep(std::time::Duration::from_secs(5));
ExitCode::SUCCESS
}
#[cfg(not(unix))]
fn signal_test_helper() -> ExitCode {
ExitCode::from(2)
}
fn install_spawn_hooks() {
fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
}
fn install_signal_handlers() {
if let Err(err) = signal::install_handlers() {
use std::io::Write as _;
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
}
}
fn redirect_report_to_file(
path: &std::path::Path,
output: fallow_config::OutputFormat,
) -> Result<(), ExitCode> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Err(emit_error(
&format!(
"failed to create {} for --output-file: {e}",
parent.display()
),
2,
output,
));
}
match std::fs::File::create(path) {
Ok(file) => {
report::sink::set_file_sink(file);
colored::control::set_override(false);
Ok(())
}
Err(e) => Err(emit_error(
&format!("failed to open {} for --output-file: {e}", path.display()),
2,
output,
)),
}
}
fn finalize_report_file(
path: &std::path::Path,
quiet: bool,
output: fallow_config::OutputFormat,
) -> Result<(), ExitCode> {
if let Err(e) = report::sink::flush() {
return Err(emit_error(
&format!("failed to write {}: {e}", path.display()),
2,
output,
));
}
if !quiet && report::sink::wrote() {
eprintln!("Report written to {}", path.display());
}
Ok(())
}
fn main() -> ExitCode {
install_signal_handlers();
install_spawn_hooks();
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
return signal_test_helper();
}
let (mut cli, fmt) = match parse_cli_args() {
Ok(parsed) => parsed,
Err(code) => return code,
};
if let Some(code) = run_schema_command_if_requested(&cli) {
return code;
}
if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output) {
return code;
}
let telemetry_run = start_telemetry_run(&cli, &fmt);
let (root, threads) = match validate_inputs(&cli, fmt.output) {
Ok(v) => v,
Err(code) => {
return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
}
};
let FormatConfig {
output,
quiet,
cli_format_was_explicit,
} = fmt;
let tolerance = match run_pre_dispatch_checks(&cli, &root, output, quiet, telemetry_run) {
Ok(tolerance) => tolerance,
Err(code) => return code,
};
let (save_regression_file, save_to_config) = regression_save_targets(&cli);
let command = cli.command.take();
let dispatch = DispatchContext {
cli: &cli,
root: &root,
output,
quiet,
cli_format_was_explicit,
threads,
tolerance,
save_regression_file: save_regression_file.as_ref(),
save_to_config,
};
let exit_code = match dispatch_and_finalize(&dispatch, command) {
Ok(code) => code,
Err(code) => return code,
};
record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
}
fn dispatch_and_finalize(
dispatch: &DispatchContext<'_>,
command: Option<Command>,
) -> Result<ExitCode, ExitCode> {
let cli = dispatch.cli;
let output = dispatch.output;
let quiet = dispatch.quiet;
if let Some(path) = cli.output_file.as_deref()
&& let Err(code) = redirect_report_to_file(path, output)
{
return Err(code);
}
let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
emit_error(bare_coverage_subcommand_error_message(), 2, output)
} else {
match command {
None => dispatch_bare_command(dispatch),
Some(cmd) => dispatch_subcommand(cmd, dispatch),
}
};
if let Some(path) = cli.output_file.as_deref()
&& let Err(code) = finalize_report_file(path, quiet, output)
{
return Err(code);
}
Ok(exit_code)
}
fn run_telemetry_command_if_requested(
cli: &mut Cli,
output: fallow_config::OutputFormat,
) -> Option<ExitCode> {
if matches!(cli.command, Some(Command::Telemetry { .. }))
&& let Some(Command::Telemetry { subcommand }) = cli.command.take()
{
return Some(telemetry::run(map_telemetry_subcommand(subcommand), output));
}
None
}
fn run_schema_command_if_requested(cli: &Cli) -> Option<ExitCode> {
match cli.command {
Some(Command::Schema) => Some(schema::run_schema()),
Some(Command::ConfigSchema) => Some(init::run_config_schema()),
Some(Command::PluginSchema) => Some(init::run_plugin_schema()),
Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema()),
_ => None,
}
}
fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
opt.as_ref()
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from)
});
let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
(save_file, save_to_config)
}
fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
let production = match dispatch.production_modes(
cli.production_dead_code,
cli.production_health,
cli.production_dupes,
) {
Ok(production) => production,
Err(code) => return code,
};
let coverage_inputs = match resolve_health_coverage_inputs(
dispatch,
cli.coverage.as_deref(),
cli.coverage_root.as_deref(),
) {
Ok(inputs) => inputs,
Err(code) => return code,
};
run_bare_combined(
dispatch,
production,
&coverage_inputs,
BareAnalyses {
run_check,
run_dupes,
run_health,
},
)
}
#[derive(Clone, Copy)]
struct BareAnalyses {
run_check: bool,
run_dupes: bool,
run_health: bool,
}
fn run_bare_combined(
dispatch: &DispatchContext<'_>,
production: ProductionModes,
coverage_inputs: &ResolvedHealthCoverageInputs,
analyses: BareAnalyses,
) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
combined::run_combined(&combined::CombinedOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
changed_since: cli.changed_since.as_deref(),
churn_file: cli.churn_file.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
production: cli.production,
production_dead_code: Some(production.dead_code),
production_health: Some(production.health),
production_dupes: Some(production.dupes),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
group_by: cli.group_by,
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
summary: cli.summary,
run_check: analyses.run_check,
run_dupes: analyses.run_dupes,
run_health: analyses.run_health,
dupes_mode: cli.dupes_mode,
dupes_threshold: cli.dupes_threshold,
dupes_min_tokens: cli.dupes_min_tokens,
dupes_min_lines: cli.dupes_min_lines,
dupes_min_occurrences: cli.dupes_min_occurrences,
dupes_skip_local: cli.dupes_skip_local,
dupes_cross_language: cli.dupes_cross_language,
dupes_ignore_imports: resolve_ignore_imports(
cli.dupes_ignore_imports,
cli.dupes_no_ignore_imports,
),
score: cli.score || cli.trend,
trend: cli.trend,
save_snapshot: cli.save_snapshot.as_ref(),
coverage: coverage_inputs.coverage.as_deref(),
coverage_root: coverage_inputs.coverage_root.as_deref(),
include_entry_exports: cli.include_entry_exports,
regression_opts: dispatch.regression_opts(
cli.changed_since.is_some()
|| cli.workspace.is_some()
|| cli.changed_workspaces.is_some(),
),
})
}
fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
match command {
check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
Command::Inspect {
file,
symbol,
symbol_chain,
} => dispatch_inspect_command(dispatch, file, symbol, symbol_chain),
Command::Trace {
symbol,
callers,
callees,
depth,
} => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
Command::Hooks { subcommand } => run_hooks_command(root, subcommand, output),
Command::Ci { subcommand } => ci::run(map_ci_subcommand(subcommand), output),
Command::ConfigSchema => init::run_config_schema(),
Command::PluginSchema => init::run_plugin_schema(),
Command::RulePackSchema => init::run_rule_pack_schema(),
Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
Command::Guard { files } => dispatch_guard_command(dispatch, &files),
Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
Command::Config { path } => config::run_config(root, cli.config.as_deref(), path, output),
list @ (Command::Workspaces | Command::List { .. }) => {
dispatch_list_command(&list, dispatch)
}
dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
Command::Flags { top } => dispatch_flags_command(dispatch, top),
Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
Command::DecisionSurface { max_decisions } => {
dispatch_decision_surface(dispatch, max_decisions)
}
Command::Impact {
subcommand,
all,
sort,
limit,
} => dispatch_impact(
root,
quiet,
output,
subcommand,
ImpactCrossRepoOpts { all, sort, limit },
),
security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
Command::Schema => unreachable!("handled above"),
migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
Command::License { subcommand } => dispatch_license_command(subcommand, output),
Command::Telemetry { .. } => unreachable!("handled before root validation"),
Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
setup_hooks @ Command::SetupHooks { .. } => {
dispatch_setup_hooks_command(&setup_hooks, dispatch)
}
}
}
fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let filters = check_issue_filters(&command);
let Command::Check {
include_dupes,
trace,
trace_file,
trace_dependency,
impact_closure,
top,
file,
..
} = command
else {
unreachable!("check dispatcher only handles check commands");
};
dispatch_check(
dispatch,
&CheckDispatchArgs {
filters,
trace_opts: TraceOptions {
trace_export: trace,
trace_file,
trace_dependency,
impact_closure,
performance: dispatch.cli.performance,
},
include_dupes,
top,
file,
},
)
}
fn check_issue_filters(command: &Command) -> IssueFilters {
check_issue_filters_framework(command, &check_issue_filters_core(command))
}
fn check_issue_filters_core(command: &Command) -> IssueFilters {
let Command::Check {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
policy_violations,
stale_suppressions,
..
} = command
else {
unreachable!("check filter builder only handles check commands");
};
let mut filters = IssueFilters::default();
for (flag, active) in [
("--unused-files", *unused_files),
("--unused-exports", *unused_exports),
("--unused-deps", *unused_deps),
("--unused-types", *unused_types),
("--private-type-leaks", *private_type_leaks),
("--unused-enum-members", *unused_enum_members),
("--unused-class-members", *unused_class_members),
("--unresolved-imports", *unresolved_imports),
("--unlisted-deps", *unlisted_deps),
("--duplicate-exports", *duplicate_exports),
("--circular-deps", *circular_deps),
("--re-export-cycles", *re_export_cycles),
("--boundary-violations", *boundary_violations),
("--policy-violations", *policy_violations),
("--stale-suppressions", *stale_suppressions),
] {
enable_check_filter(&mut filters, flag, active);
}
filters
}
fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
let Command::Check {
unused_store_members,
unprovided_injects,
unrendered_components,
unused_component_props,
unused_component_emits,
unused_component_inputs,
unused_component_outputs,
unused_svelte_events,
unused_server_actions,
unused_load_data_keys,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
..
} = command
else {
unreachable!("check filter builder only handles check commands");
};
let mut filters = base.clone();
for (flag, active) in [
("--unused-store-members", *unused_store_members),
("--unprovided-injects", *unprovided_injects),
("--unrendered-components", *unrendered_components),
("--unused-component-props", *unused_component_props),
("--unused-component-emits", *unused_component_emits),
("--unused-component-inputs", *unused_component_inputs),
("--unused-component-outputs", *unused_component_outputs),
("--unused-svelte-events", *unused_svelte_events),
("--unused-server-actions", *unused_server_actions),
("--unused-load-data-keys", *unused_load_data_keys),
("--unused-catalog-entries", *unused_catalog_entries),
("--empty-catalog-groups", *empty_catalog_groups),
(
"--unresolved-catalog-references",
*unresolved_catalog_references,
),
(
"--unused-dependency-overrides",
*unused_dependency_overrides,
),
(
"--misconfigured-dependency-overrides",
*misconfigured_dependency_overrides,
),
] {
enable_check_filter(&mut filters, flag, active);
}
filters
}
fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
if active {
assert!(
filters.enable_cli_filter_flag(flag),
"check command uses unregistered dead-code filter flag {flag}"
);
}
}
fn dispatch_inspect_command(
dispatch: &DispatchContext<'_>,
file: Option<String>,
symbol: Option<String>,
symbol_chain: bool,
) -> ExitCode {
let target = match (file, symbol) {
(Some(file), None) => inspect::InspectTarget::File { file },
(None, Some(symbol)) => match symbol.rsplit_once(':') {
Some((file, export_name))
if !file.trim().is_empty() && !export_name.trim().is_empty() =>
{
inspect::InspectTarget::Symbol {
file: file.to_string(),
export_name: export_name.to_string(),
}
}
_ => {
return emit_error(
"--symbol must be formatted as FILE:EXPORT",
2,
dispatch.output,
);
}
},
_ => {
return emit_error(
"inspect requires exactly one of --file or --symbol",
2,
dispatch.output,
);
}
};
inspect::run_inspect(&inspect::InspectOptions {
root: dispatch.root,
config_path: dispatch.cli.config.as_ref(),
output: dispatch.output,
no_cache: dispatch.cli.no_cache,
no_production: dispatch.cli.no_production,
max_file_size: dispatch.cli.max_file_size,
threads: dispatch.threads,
quiet: dispatch.quiet,
production: dispatch.cli.production,
workspace: dispatch.cli.workspace.as_ref(),
target,
symbol_chain,
})
}
fn dispatch_trace_command(
dispatch: &DispatchContext<'_>,
symbol: String,
callers: bool,
callees: bool,
depth: Option<u32>,
) -> ExitCode {
trace_chain::run_trace(&trace_chain::TraceChainOptions {
root: dispatch.root,
config_path: &dispatch.cli.config,
output: dispatch.output,
no_cache: dispatch.cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
target: symbol,
callers,
callees,
depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
})
}
fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Security {
subcommand,
runtime_coverage,
min_invocations_hot,
file,
gate,
surface,
} = command
else {
unreachable!("security dispatcher only handles security commands");
};
let gate = gate.map(security::SecurityGateArg::into_mode);
let cli = dispatch.cli;
let (output, _quiet, fail_on_issues) = dispatch.ci_defaults();
let derived_flags = SecurityDerivedFlagState {
output,
ci: cli.ci,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
summary: cli.summary,
explain: cli.explain,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
file: file.as_slice(),
gate,
surface,
};
if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
return code;
}
let scoped_files = scoped_security_files(&file, subcommand.as_ref());
run_security_blind_spots_or_default(
dispatch,
&SecurityRunInputs {
scoped_files: &scoped_files,
subcommand: &subcommand,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
gate,
surface,
},
&derived_flags,
)
}
struct SecurityRunInputs<'a> {
scoped_files: &'a [PathBuf],
subcommand: &'a Option<SecuritySubcommand>,
runtime_coverage: Option<&'a Path>,
min_invocations_hot: u64,
gate: Option<security::SecurityGateMode>,
surface: bool,
}
fn run_security_blind_spots_or_default(
dispatch: &DispatchContext<'_>,
inputs: &SecurityRunInputs<'_>,
derived_flags: &SecurityDerivedFlagState<'_>,
) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
let opts = security::SecurityOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
fail_on_issues,
sarif_file: cli.sarif_file.as_deref(),
summary: cli.summary,
changed_since: cli.changed_since.as_deref(),
use_shared_diff_index: true,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
file: inputs.scoped_files,
surface: inputs.surface,
gate: inputs.gate,
runtime_coverage: inputs.runtime_coverage,
min_invocations_hot: inputs.min_invocations_hot,
explain: cli.explain,
};
if matches!(
inputs.subcommand,
Some(SecuritySubcommand::BlindSpots { .. })
) {
if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
return code;
}
security::run_blind_spots(&opts)
} else {
security::run(&opts)
}
}
fn try_run_security_survivors(
subcommand: Option<&SecuritySubcommand>,
flags: &SecurityDerivedFlagState<'_>,
) -> Option<ExitCode> {
let Some(SecuritySubcommand::Survivors {
candidates,
verdicts,
require_verdict_for_each_candidate,
}) = subcommand
else {
return None;
};
if let Some(code) = validate_security_survivors_flags(flags) {
return Some(code);
}
Some(security::run_survivors(
&security::SecuritySurvivorsOptions {
output: flags.output,
candidates,
verdicts,
require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
},
))
}
fn scoped_security_files(
file: &[PathBuf],
subcommand: Option<&SecuritySubcommand>,
) -> Vec<PathBuf> {
let mut scoped_files = file.to_vec();
if let Some(SecuritySubcommand::BlindSpots {
file: blind_spot_files,
}) = subcommand
{
scoped_files.extend(blind_spot_files.iter().cloned());
}
scoped_files
}
struct SecurityDerivedFlagState<'a> {
output: fallow_config::OutputFormat,
ci: bool,
fail_on_issues: bool,
sarif_file: Option<&'a Path>,
summary: bool,
explain: bool,
runtime_coverage: Option<&'a Path>,
min_invocations_hot: u64,
file: &'a [PathBuf],
gate: Option<security::SecurityGateMode>,
surface: bool,
}
fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
let flag = if flags.ci {
Some("--ci")
} else if flags.fail_on_issues {
Some("--fail-on-issues")
} else if flags.sarif_file.is_some() {
Some("--sarif-file")
} else if flags.summary {
Some("--summary")
} else if flags.explain {
Some("--explain")
} else if flags.runtime_coverage.is_some() {
Some("--runtime-coverage")
} else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
Some("--min-invocations-hot")
} else if !flags.file.is_empty() {
Some("--file")
} else if flags.gate.is_some() {
Some("--gate")
} else if flags.surface {
Some("--surface")
} else {
None
}?;
Some(emit_error(
&format!("{flag} is not valid with `fallow security survivors`."),
2,
flags.output,
))
}
fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
let flag = if flags.ci {
Some("--ci")
} else if flags.fail_on_issues {
Some("--fail-on-issues")
} else if flags.sarif_file.is_some() {
Some("--sarif-file")
} else if flags.summary {
Some("--summary")
} else if flags.explain {
Some("--explain")
} else if flags.runtime_coverage.is_some() {
Some("--runtime-coverage")
} else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
Some("--min-invocations-hot")
} else if flags.gate.is_some() {
Some("--gate")
} else if flags.surface {
Some("--surface")
} else {
None
}?;
Some(emit_error(
&format!("{flag} is not valid with `fallow security blind-spots`."),
2,
flags.output,
))
}
fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Dupes {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
no_ignore_imports,
top,
trace,
} = command
else {
unreachable!("dupes dispatcher only handles dupes commands");
};
dispatch_dupes(
dispatch,
&DupesDispatchArgs {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
no_ignore_imports,
top,
trace,
},
)
}
fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
let Command::Init {
toml,
agents,
hooks,
branch,
decline,
} = command
else {
unreachable!("init dispatcher only handles init commands");
};
init::run_init(&init::InitOptions {
root,
use_toml: toml,
agents,
hooks,
branch: branch.as_deref(),
decline,
quiet,
})
}
fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Fix {
dry_run,
yes,
no_create_config,
} = command
else {
unreachable!("fix dispatcher only handles fix commands");
};
dispatch_fix(
dispatch,
FixDispatchArgs {
dry_run: *dry_run,
yes: *yes,
no_create_config: *no_create_config,
},
)
}
fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
match command {
Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
Command::List {
entry_points,
files,
plugins,
boundaries,
workspaces,
} => dispatch_list(
dispatch,
ListDispatchArgs {
entry_points: *entry_points,
files: *files,
plugins: *plugins,
boundaries: *boundaries,
workspaces: *workspaces,
},
),
_ => unreachable!("list dispatcher only handles list commands"),
}
}
fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
let Command::Migrate {
toml,
jsonc,
dry_run,
from,
} = command
else {
unreachable!("migrate dispatcher only handles migrate commands");
};
migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
}
fn dispatch_license_command(
subcommand: LicenseCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
license::run(&map_license_subcommand(subcommand), output)
}
fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
match subcommand {
CiTemplateCli::Gitlab { vendor, force } => {
ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
vendor_dir: vendor,
force,
})
}
}
}
fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
let cli = dispatch.cli;
coverage::run(
map_coverage_subcommand(subcommand, cli.explain),
&coverage::RunContext {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
quiet: dispatch.quiet,
no_cache: cli.no_cache,
threads: dispatch.threads,
explain: cli.explain,
},
)
}
fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Health {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
complexity_breakdown,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
css,
effort,
score,
min_score,
min_severity,
report_only,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
runtime_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} = command
else {
unreachable!("health dispatcher only handles health commands");
};
let ownership = ownership || ownership_emails.is_some();
let hotspots = hotspots || ownership;
let args = HealthDispatchArgs {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
complexity_breakdown,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails: ownership_emails.map(EmailModeArg::to_config),
targets,
css,
effort,
score,
min_score,
min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
report_only,
since: since.as_deref(),
min_commits,
save_snapshot: save_snapshot.as_ref(),
trend,
coverage: coverage.as_deref(),
coverage_root: coverage_root.as_deref(),
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
};
dispatch_health(dispatch, &args)
}
fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::SetupHooks {
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
} = command
else {
unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
};
setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
root: dispatch.root,
agent: *agent,
dry_run: *dry_run,
force: *force,
user: *user,
gitignore_claude: *gitignore_claude,
uninstall: *uninstall,
})
}
fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let Command::Audit {
production_dead_code,
production_health,
production_dupes,
dead_code_baseline,
health_baseline,
dupes_baseline,
max_crap,
coverage,
coverage_root,
no_css,
css_deep,
no_css_deep,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
brief,
max_decisions,
walkthrough_guide,
walkthrough_file,
walkthrough,
mark_viewed,
show_cleared,
show_deprioritized,
} = command
else {
unreachable!("audit dispatcher only handles audit commands");
};
let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
dispatch_audit(
dispatch,
&AuditDispatchArgs {
production_dead_code,
production_health,
production_dupes,
dead_code_baseline,
health_baseline,
dupes_baseline,
max_crap,
coverage,
coverage_root,
no_css,
css_deep,
no_css_deep,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
brief,
max_decisions,
walkthrough_guide,
walkthrough_file,
walkthrough,
mark_viewed,
show_cleared,
show_deprioritized,
},
)
}
fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
let threads = dispatch.threads;
let production = match resolve_production_modes(cli, root, output, false, false, false) {
Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
Err(code) => return code,
};
flags::run_flags(&flags::FlagsOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
changed_since: cli.changed_since.as_deref(),
explain: cli.explain,
top,
})
}
fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
guard::run_guard(&guard::GuardOptions {
root: dispatch.root,
config_path: &dispatch.cli.config,
output: dispatch.output,
quiet: dispatch.quiet,
files,
})
}
fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
let ctx = rule_pack::RulePackContext {
root: dispatch.root,
config_path: &dispatch.cli.config,
output: dispatch.output,
quiet: dispatch.quiet,
no_cache: dispatch.cli.no_cache,
threads: Some(dispatch.threads),
};
rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
}
fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
match subcommand {
RulePackCli::Init {
name,
template,
dir,
no_config,
} => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
name,
template,
dir,
no_config,
}),
RulePackCli::List => rule_pack::RulePackSubcommand::List,
RulePackCli::Test { pack } => {
rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
}
RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
}
}
fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
match sub {
LicenseCli::Activate {
jwt,
from_file,
stdin,
trial,
email,
} => license::LicenseSubcommand::Activate(license::ActivateArgs {
raw_jwt: jwt,
from_file,
from_stdin: stdin,
trial,
email,
}),
LicenseCli::Status => license::LicenseSubcommand::Status,
LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
}
}
fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
match sub {
TelemetryCli::Status => telemetry::TelemetryCommand::Status,
TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
}
}
fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
match sub {
command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
command @ CiCli::PostReview { .. } => map_ci_post_review(command),
command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
}
}
fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
let CiCli::PlanPrComment {
body,
marker_id,
clean,
existing_comment_id,
existing_body,
} = command
else {
unreachable!("ci plan-pr-comment mapper called with different variant");
};
ci::CiCommand::PlanPrComment {
body,
marker_id,
clean,
existing_comment_id,
existing_body,
}
}
fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
let CiCli::PostPrComment {
provider,
pr,
mr,
body,
envelope,
marker_id,
clean,
repo,
project_id,
api_url,
dry_run,
} = command
else {
unreachable!("ci post-pr-comment mapper called with different variant");
};
ci::CiCommand::PostPrComment {
provider: map_ci_provider(provider),
target: pr.or(mr),
body,
envelope,
marker_id,
clean,
repo,
project_id,
api_url,
dry_run,
}
}
fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
let CiCli::PostReview {
provider,
pr,
mr,
envelope,
repo,
project_id,
api_url,
dry_run,
} = command
else {
unreachable!("ci post-review mapper called with different variant");
};
ci::CiCommand::PostReview {
provider: map_ci_provider(provider),
target: pr.or(mr),
envelope,
repo,
project_id,
api_url,
dry_run,
}
}
fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
let CiCli::PostCheckRun {
provider,
decision,
repo,
head_sha,
api_url,
split_gates,
dry_run,
} = command
else {
unreachable!("ci post-check-run mapper called with different variant");
};
ci::CiCommand::PostCheckRun {
provider: map_ci_provider(provider),
decision,
repo,
head_sha,
api_url,
split_gates,
dry_run,
}
}
fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
let CiCli::ReconcileReview {
provider,
pr,
mr,
envelope,
repo,
project_id,
api_url,
dry_run,
} = command
else {
unreachable!("ci reconcile-review mapper called with different variant");
};
ci::CiCommand::ReconcileReview {
provider: map_ci_provider(provider),
target: pr.or(mr),
envelope,
repo,
project_id,
api_url,
dry_run,
}
}
fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
match provider {
CiProviderArg::Github => ci::CiProvider::Github,
CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
}
}
fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
match sub {
CoverageCli::Setup {
yes,
non_interactive,
json,
} => map_coverage_setup(*yes, *non_interactive, *json, explain),
CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
}
}
fn map_coverage_setup(
yes: bool,
non_interactive: bool,
json: bool,
explain: bool,
) -> coverage::CoverageSubcommand {
coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
yes,
non_interactive: non_interactive || json,
json,
explain,
})
}
fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
let CoverageCli::Analyze {
runtime_coverage,
cloud,
api_key,
api_endpoint,
repo,
project_id,
coverage_period,
environment,
commit_sha,
production,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
top,
blast_radius,
importance,
} = sub
else {
unreachable!("coverage analyze mapper called with non-analyze variant");
};
coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
runtime_coverage: runtime_coverage.clone(),
cloud: *cloud,
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
repo: repo.clone(),
project_id: project_id.clone(),
coverage_period: *coverage_period,
environment: environment.clone(),
commit_sha: commit_sha.clone(),
production: *production,
min_invocations_hot: *min_invocations_hot,
min_observation_volume: *min_observation_volume,
low_traffic_threshold: *low_traffic_threshold,
top: *top,
blast_radius: *blast_radius,
importance: *importance,
})
}
fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
let CoverageCli::UploadInventory {
api_key,
api_endpoint,
project_id,
git_sha,
allow_dirty,
exclude_paths,
path_prefix,
dry_run,
with_callers,
ignore_upload_errors,
} = sub
else {
unreachable!("coverage inventory mapper called with non-inventory variant");
};
coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
project_id: project_id.clone(),
git_sha: git_sha.clone(),
allow_dirty: *allow_dirty,
exclude_paths: exclude_paths.clone(),
path_prefix: path_prefix.clone(),
dry_run: *dry_run,
with_callers: *with_callers,
ignore_upload_errors: *ignore_upload_errors,
})
}
fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
let CoverageCli::UploadSourceMaps {
dir,
include,
exclude,
repo,
git_sha,
endpoint,
strip_path,
dry_run,
concurrency,
fail_fast,
} = sub
else {
unreachable!("coverage source-map mapper called with non-source-map variant");
};
coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
dir: dir.clone(),
include: include.clone(),
exclude: exclude.clone(),
repo: repo.clone(),
git_sha: git_sha.clone(),
endpoint: endpoint.clone(),
strip_path: *strip_path,
dry_run: *dry_run,
concurrency: *concurrency,
fail_fast: *fail_fast,
})
}
fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
let CoverageCli::UploadStaticFindings {
api_key,
api_endpoint,
project_id,
git_sha,
allow_dirty,
dry_run,
ignore_upload_errors,
} = sub
else {
unreachable!("coverage static-findings mapper called with non-static variant");
};
coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
api_key: api_key.clone(),
api_endpoint: api_endpoint.clone(),
project_id: project_id.clone(),
git_sha: git_sha.clone(),
allow_dirty: *allow_dirty,
dry_run: *dry_run,
ignore_upload_errors: *ignore_upload_errors,
})
}
struct CheckDispatchArgs {
filters: IssueFilters,
trace_opts: TraceOptions,
include_dupes: bool,
top: Option<usize>,
file: Vec<std::path::PathBuf>,
}
#[derive(Clone, Copy)]
struct ListDispatchArgs {
entry_points: bool,
files: bool,
plugins: bool,
boundaries: bool,
workspaces: bool,
}
impl ListDispatchArgs {
fn workspaces() -> Self {
Self {
entry_points: false,
files: false,
plugins: false,
boundaries: false,
workspaces: true,
}
}
}
fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
watch::run_watch(&watch::WatchOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
production,
clear_screen: !no_clear,
explain: cli.explain,
include_entry_exports: cli.include_entry_exports,
})
}
#[derive(Clone, Copy)]
struct FixDispatchArgs {
dry_run: bool,
yes: bool,
no_create_config: bool,
}
fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
fix::run_fix(&fix::FixOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
dry_run: args.dry_run,
yes: args.yes,
production,
no_create_config: args.no_create_config,
})
}
fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
list::run_list(&ListOptions {
root: dispatch.root,
config_path: &cli.config,
output: dispatch.output,
threads: dispatch.threads,
no_cache: cli.no_cache,
entry_points: args.entry_points,
files: args.files,
plugins: args.plugins,
boundaries: args.boundaries,
workspaces: args.workspaces,
production,
})
}
fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
Ok(production) => production,
Err(code) => return code,
};
check::run_check(&CheckOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
fail_on_issues,
filters: &args.filters,
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
sarif_file: cli.sarif_file.as_deref(),
production,
production_override: Some(production),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
group_by: cli.group_by,
include_dupes: args.include_dupes,
trace_opts: &args.trace_opts,
explain: cli.explain,
top: args.top,
file: &args.file,
include_entry_exports: cli.include_entry_exports,
summary: cli.summary,
regression_opts: dispatch.regression_opts(
cli.changed_since.is_some()
|| cli.workspace.is_some()
|| cli.changed_workspaces.is_some()
|| !args.file.is_empty(),
),
retain_modules_for_health: false,
defer_performance: false,
})
}
fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
if no_ignore_imports {
Some(false)
} else if ignore_imports {
Some(true)
} else {
None
}
}
struct DupesDispatchArgs {
mode: Option<DupesMode>,
min_tokens: Option<usize>,
min_lines: Option<usize>,
min_occurrences: Option<usize>,
threshold: Option<f64>,
skip_local: bool,
cross_language: bool,
ignore_imports: bool,
no_ignore_imports: bool,
top: Option<usize>,
trace: Option<String>,
}
fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
Ok(production) => production,
Err(code) => return code,
};
dupes::run_dupes(&DupesOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
mode: args.mode,
min_tokens: args.min_tokens,
min_lines: args.min_lines,
min_occurrences: args.min_occurrences,
threshold: args.threshold,
skip_local: args.skip_local,
cross_language: args.cross_language,
ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
top: args.top,
baseline_path: cli.baseline.as_deref(),
save_baseline_path: cli.save_baseline.as_deref(),
production,
production_override: Some(production),
trace: args.trace.as_deref(),
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
changed_files: None,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
summary: cli.summary,
group_by: cli.group_by,
performance: cli.performance,
})
}
struct AuditDispatchArgs {
production_dead_code: bool,
production_health: bool,
production_dupes: bool,
dead_code_baseline: Option<PathBuf>,
health_baseline: Option<PathBuf>,
dupes_baseline: Option<PathBuf>,
max_crap: Option<f64>,
coverage: Option<PathBuf>,
coverage_root: Option<PathBuf>,
no_css: bool,
css_deep: bool,
no_css_deep: bool,
gate: Option<AuditGateArg>,
runtime_coverage: Option<PathBuf>,
min_invocations_hot: u64,
gate_marker: Option<String>,
brief: bool,
max_decisions: usize,
walkthrough_guide: bool,
walkthrough_file: Option<PathBuf>,
walkthrough: bool,
mark_viewed: Vec<PathBuf>,
show_cleared: bool,
show_deprioritized: bool,
}
struct ResolvedAuditInputs {
audit_cfg: fallow_config::AuditConfig,
cache_dir: PathBuf,
production: ProductionModes,
dead_code_baseline: Option<PathBuf>,
health_baseline: Option<PathBuf>,
dupes_baseline: Option<PathBuf>,
coverage: Option<PathBuf>,
}
fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
let cli = dispatch.cli;
let output = dispatch.output;
if cli.baseline.is_some() || cli.save_baseline.is_some() {
return emit_error(
"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>`)",
2,
output,
);
}
let inputs = match resolve_audit_inputs(dispatch, args) {
Ok(inputs) => inputs,
Err(code) => return code,
};
run_resolved_audit(dispatch, args, &inputs)
}
fn resolve_audit_inputs(
dispatch: &DispatchContext<'_>,
args: &AuditDispatchArgs,
) -> Result<ResolvedAuditInputs, ExitCode> {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let config = load_config(
root,
&cli.config,
LoadConfigArgs {
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
production: cli.production,
quiet: dispatch.quiet,
},
)?;
let cache_dir = config.cache_dir.clone();
let audit_cfg = config.audit;
let production = resolve_production_modes(
cli,
root,
output,
args.production_dead_code,
args.production_health,
args.production_dupes,
)?;
let resolved_dead_code_baseline = resolve_audit_baseline_path(
root,
args.dead_code_baseline.as_deref(),
audit_cfg.dead_code_baseline.as_deref(),
);
let resolved_health_baseline = resolve_audit_baseline_path(
root,
args.health_baseline.as_deref(),
audit_cfg.health_baseline.as_deref(),
);
let resolved_dupes_baseline = resolve_audit_baseline_path(
root,
args.dupes_baseline.as_deref(),
audit_cfg.dupes_baseline.as_deref(),
);
let coverage = args
.coverage
.clone()
.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
Ok(ResolvedAuditInputs {
audit_cfg,
cache_dir,
production,
dead_code_baseline: resolved_dead_code_baseline,
health_baseline: resolved_health_baseline,
dupes_baseline: resolved_dupes_baseline,
coverage,
})
}
fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
!args.no_css && config.css.unwrap_or(true)
}
fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
audit_css_enabled(config, args)
&& !args.no_css_deep
&& (args.css_deep || config.css_deep.unwrap_or(true))
}
fn run_resolved_audit(
dispatch: &DispatchContext<'_>,
args: &AuditDispatchArgs,
inputs: &ResolvedAuditInputs,
) -> ExitCode {
let cli = dispatch.cli;
audit::run_audit(
&audit::AuditOptions {
root: dispatch.root,
config_path: &cli.config,
cache_dir: &inputs.cache_dir,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
changed_since: cli.changed_since.as_deref(),
production: cli.production,
production_dead_code: Some(inputs.production.dead_code),
production_health: Some(inputs.production.health),
production_dupes: Some(inputs.production.dupes),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
group_by: cli.group_by,
dead_code_baseline: inputs.dead_code_baseline.as_deref(),
health_baseline: inputs.health_baseline.as_deref(),
dupes_baseline: inputs.dupes_baseline.as_deref(),
max_crap: args.max_crap,
coverage: inputs.coverage.as_deref(),
coverage_root: args.coverage_root.as_deref(),
gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
include_entry_exports: cli.include_entry_exports,
css: audit_css_enabled(&inputs.audit_cfg, args),
css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
runtime_coverage: args.runtime_coverage.as_deref(),
min_invocations_hot: args.min_invocations_hot,
brief: args.brief,
max_decisions: args.max_decisions,
walkthrough_guide: args.walkthrough_guide,
walkthrough: args.walkthrough,
mark_viewed: &args.mark_viewed,
show_cleared: args.show_cleared,
walkthrough_file: args.walkthrough_file.as_deref(),
show_deprioritized: args.show_deprioritized,
},
args.gate_marker.as_deref(),
)
}
fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
let args = decision_surface_audit_args(max_decisions);
let inputs = match resolve_audit_inputs(dispatch, &args) {
Ok(inputs) => inputs,
Err(code) => return code,
};
audit::run_decision_surface(&decision_surface_audit_options(
dispatch,
&inputs,
max_decisions,
))
}
fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
AuditDispatchArgs {
production_dead_code: false,
production_health: false,
production_dupes: false,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
max_crap: None,
coverage: None,
coverage_root: None,
no_css: true,
css_deep: false,
no_css_deep: false,
gate: None,
runtime_coverage: None,
min_invocations_hot: 0,
gate_marker: None,
brief: true,
max_decisions,
walkthrough_guide: false,
walkthrough_file: None,
walkthrough: false,
mark_viewed: Vec::new(),
show_cleared: false,
show_deprioritized: false,
}
}
fn decision_surface_audit_options<'a>(
dispatch: &'a DispatchContext<'a>,
inputs: &'a ResolvedAuditInputs,
max_decisions: usize,
) -> audit::AuditOptions<'a> {
let cli = dispatch.cli;
audit::AuditOptions {
root: dispatch.root,
config_path: &cli.config,
cache_dir: &inputs.cache_dir,
output: dispatch.output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet: dispatch.quiet,
changed_since: cli.changed_since.as_deref(),
production: cli.production,
production_dead_code: Some(inputs.production.dead_code),
production_health: Some(inputs.production.health),
production_dupes: Some(inputs.production.dupes),
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
group_by: cli.group_by,
dead_code_baseline: inputs.dead_code_baseline.as_deref(),
health_baseline: inputs.health_baseline.as_deref(),
dupes_baseline: inputs.dupes_baseline.as_deref(),
max_crap: None,
coverage: None,
coverage_root: None,
gate: inputs.audit_cfg.gate,
include_entry_exports: cli.include_entry_exports,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 0,
brief: true,
max_decisions,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
}
}
struct HealthDispatchArgs<'a> {
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
max_crap: Option<f64>,
top: Option<usize>,
sort: health::SortBy,
complexity: bool,
complexity_breakdown: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
ownership: bool,
ownership_emails: Option<fallow_config::EmailMode>,
targets: bool,
css: bool,
effort: Option<EffortFilter>,
score: bool,
min_score: Option<f64>,
min_severity: Option<fallow_output::FindingSeverity>,
report_only: bool,
since: Option<&'a str>,
min_commits: Option<u32>,
save_snapshot: Option<&'a Option<String>>,
trend: bool,
coverage: Option<&'a std::path::Path>,
coverage_root: Option<&'a std::path::Path>,
runtime_coverage: Option<&'a std::path::Path>,
min_invocations_hot: u64,
min_observation_volume: Option<u32>,
low_traffic_threshold: Option<f64>,
}
struct ResolvedHealthCoverageInputs {
coverage: Option<PathBuf>,
coverage_root: Option<PathBuf>,
}
fn resolve_health_coverage_inputs(
dispatch: &DispatchContext<'_>,
cli_coverage: Option<&std::path::Path>,
cli_coverage_root: Option<&std::path::Path>,
) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
let env_coverage = path_from_env("FALLOW_COVERAGE");
let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
let config_health = if needs_config_coverage || needs_config_coverage_root {
Some(
load_config(
dispatch.root,
&dispatch.cli.config,
LoadConfigArgs {
output: dispatch.output,
no_cache: dispatch.cli.no_cache,
threads: dispatch.threads,
production: dispatch.cli.production,
quiet: dispatch.quiet,
},
)?
.health,
)
} else {
None
};
Ok(ResolvedHealthCoverageInputs {
coverage: cli_coverage
.map(std::path::Path::to_path_buf)
.or(env_coverage)
.or_else(|| {
config_health
.as_ref()
.and_then(|health| health.coverage.clone())
}),
coverage_root: cli_coverage_root
.map(std::path::Path::to_path_buf)
.or(env_coverage_root)
.or_else(|| {
config_health
.as_ref()
.and_then(|health| health.coverage_root.clone())
}),
})
}
fn path_from_env(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn validate_health_report_only_gate(
report_only: bool,
min_score: Option<f64>,
min_severity: Option<fallow_output::FindingSeverity>,
output: fallow_config::OutputFormat,
) -> Result<(), ExitCode> {
if report_only && (min_score.is_some() || min_severity.is_some()) {
return Err(emit_error(
"--report-only cannot be combined with --min-score or --min-severity. \
--report-only always exits 0; drop it to gate on score/severity, or \
drop the gate flags to stay advisory.",
2,
output,
));
}
Ok(())
}
fn resolve_runtime_coverage_options(
runtime_coverage: Option<&std::path::Path>,
min_invocations_hot: u64,
min_observation_volume: Option<u32>,
low_traffic_threshold: Option<f64>,
output: fallow_config::OutputFormat,
) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
let Some(path) = runtime_coverage else {
return Ok(None);
};
health::coverage::prepare_options(
path,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
output,
)
.map(Some)
}
fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let (output, _quiet, _fail_on_issues) = dispatch.ci_defaults();
if let Err(code) = validate_health_report_only_gate(
args.report_only,
args.min_score,
args.min_severity,
output,
) {
return code;
}
let runtime_coverage = match resolve_runtime_coverage_options(
args.runtime_coverage,
args.min_invocations_hot,
args.min_observation_volume,
args.low_traffic_threshold,
output,
) {
Ok(options) => options,
Err(code) => return code,
};
let production = match resolve_production_modes(cli, root, output, false, false, false) {
Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
Err(code) => return code,
};
let coverage_inputs =
match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
Ok(inputs) => inputs,
Err(code) => return code,
};
let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
}
fn derive_health_dispatch_run<'a>(
args: &'a HealthDispatchArgs<'a>,
output: fallow_config::OutputFormat,
coverage_inputs: &'a ResolvedHealthCoverageInputs,
runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
) -> fallow_engine::health::HealthRunOptions<'a> {
fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
output,
thresholds: health_threshold_overrides(args),
top: args.top,
sort: args.sort.clone().into(),
complexity: args.complexity,
file_scores: args.file_scores,
coverage_gaps: args.coverage_gaps,
hotspots: args.hotspots,
ownership: args.ownership,
ownership_emails: args.ownership_emails,
targets: args.targets,
css: args.css,
effort: args.effort.map(EffortFilter::to_estimate),
score: args.score,
gates: health_gate_options(args),
snapshot_requested: args.save_snapshot.is_some(),
trend: args.trend,
since: args.since,
min_commits: args.min_commits,
coverage_inputs: health_coverage_inputs(coverage_inputs),
runtime_coverage,
})
}
fn health_threshold_overrides(
args: &HealthDispatchArgs<'_>,
) -> fallow_engine::health::HealthThresholdOverrides {
fallow_engine::health::HealthThresholdOverrides {
max_cyclomatic: args.max_cyclomatic,
max_cognitive: args.max_cognitive,
max_crap: args.max_crap,
}
}
fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
fallow_engine::health::HealthGateOptions {
min_score: args.min_score,
min_severity: args.min_severity,
report_only: args.report_only,
}
}
fn health_coverage_inputs(
coverage_inputs: &ResolvedHealthCoverageInputs,
) -> fallow_engine::health::HealthCoverageInputs<'_> {
fallow_engine::health::HealthCoverageInputs {
coverage: coverage_inputs.coverage.as_deref(),
coverage_root: coverage_inputs.coverage_root.as_deref(),
}
}
struct ResolvedHealthDispatch<'a> {
run: fallow_engine::health::HealthRunOptions<'a>,
production: bool,
}
fn run_health_dispatch(
dispatch: &DispatchContext<'_>,
args: &HealthDispatchArgs<'_>,
resolved: ResolvedHealthDispatch<'_>,
) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
let run = resolved.run;
let sections = run.sections;
let production = resolved.production;
health::run_health(&HealthOptions {
root: dispatch.root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads: dispatch.threads,
quiet,
thresholds: run.thresholds,
top: run.top,
sort: run.sort,
production,
production_override: Some(production),
changed_since: cli.changed_since.as_deref(),
diff_index: None,
use_shared_diff_index: true,
workspace: cli.workspace.as_deref(),
changed_workspaces: cli.changed_workspaces.as_deref(),
baseline: cli.baseline.as_deref(),
save_baseline: cli.save_baseline.as_deref(),
complexity: sections.complexity,
file_scores: sections.file_scores,
coverage_gaps: sections.coverage_gaps,
config_activates_coverage_gaps: !sections.any_section,
hotspots: sections.hotspots,
ownership: run.ownership,
ownership_emails: run.ownership_emails,
targets: sections.targets,
css: sections.css,
css_deep: false,
force_full: sections.force_full,
score_only_output: sections.score_only_output,
enforce_coverage_gap_gate: true,
effort: run.effort,
score: sections.score,
gates: run.gates,
since: run.since,
min_commits: run.min_commits,
explain: cli.explain,
summary: cli.summary,
save_snapshot: args
.save_snapshot
.map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
trend: args.trend,
coverage_inputs: run.coverage_inputs,
performance: cli.performance,
runtime_coverage: run.runtime_coverage,
churn_file: cli.churn_file.as_deref(),
complexity_breakdown: args.complexity_breakdown,
group_by: cli.group_by.map(Into::into),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_has_no_flag_collisions() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn after_help_lists_every_task_matrix_command() {
for row in crate::task_matrix::TASK_MATRIX {
assert!(
TOP_LEVEL_AFTER_HELP.contains(row.command),
"root --help cheat sheet is missing task-matrix command '{}'; \
update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
row.command
);
}
}
#[test]
fn high_value_commands_route_to_distinct_workflows() {
use clap::Parser;
use fallow_config::OutputFormat;
let distinct = [
(vec!["fallow", "impact"], telemetry::Workflow::Impact),
(vec!["fallow", "security"], telemetry::Workflow::Security),
(vec!["fallow", "fix"], telemetry::Workflow::Fix),
(
vec!["fallow", "explain", "unused-exports"],
telemetry::Workflow::Explain,
),
(
vec!["fallow", "watch"],
telemetry::Workflow::CodeQualityReview,
),
(
vec!["fallow", "list"],
telemetry::Workflow::ProjectInventory,
),
(
vec!["fallow", "workspaces"],
telemetry::Workflow::ProjectInventory,
),
(
vec!["fallow", "schema"],
telemetry::Workflow::ProjectInventory,
),
(vec!["fallow", "init"], telemetry::Workflow::Setup),
(
vec!["fallow", "hooks", "install", "--target", "git"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
(vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
(
vec!["fallow", "rule-pack-schema"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "config"], telemetry::Workflow::Setup),
(
vec!["fallow", "ci-template", "gitlab"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "migrate"], telemetry::Workflow::Setup),
(
vec!["fallow", "telemetry", "status"],
telemetry::Workflow::Setup,
),
(vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
(
vec!["fallow", "license", "status"],
telemetry::Workflow::License,
),
];
for (argv, expected) in distinct {
let cli = Cli::try_parse_from(&argv).expect("argv parses");
assert_eq!(
telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
expected,
"{argv:?} should map to {expected:?}"
);
}
}
#[test]
fn version_flag_accepts_lower_v_upper_v_and_long() {
use clap::CommandFactory;
for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
let err = Cli::command()
.try_get_matches_from(argv)
.expect_err("version flag should short-circuit parsing");
assert_eq!(
err.kind(),
clap::error::ErrorKind::DisplayVersion,
"{argv:?} should trigger the Version action"
);
}
}
#[test]
fn cli_help_text_contains_no_implementation_status_wording() {
use clap::CommandFactory;
let mut root = Cli::command();
let mut violations: Vec<(String, String)> = Vec::new();
visit_help(&mut root, "fallow", &mut violations);
assert!(
violations.is_empty(),
"found implementation-status wording in --help output:\n{}",
violations
.iter()
.map(|(cmd, line)| format!(" {cmd}: {line}"))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn top_level_help_groups_commands_by_workflow() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
let expected_order = [
"Analysis:",
" dead-code",
" dupes",
" health",
" flags",
" security",
" audit",
"Workflow:",
" watch",
" fix",
"Project inspection:",
" list",
" workspaces",
" explain",
" impact",
"Setup and configuration:",
" init",
" migrate",
" config",
" config-schema",
" plugin-schema",
" rule-pack-schema",
"Automation and CI:",
" ci",
" ci-template",
" hooks",
" setup-hooks",
"Runtime coverage:",
" coverage",
" license",
"Reference:",
" schema",
" help",
"Options:",
];
let mut cursor = 0;
for needle in expected_order {
let Some(offset) = help[cursor..].find(needle) else {
panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
};
cursor += offset + needle.len();
}
}
#[test]
fn security_help_hides_globals_rejected_by_security_validator() {
let help = render_security_help(SecurityHelpTarget::Parent);
for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
assert!(
!help_contains_long_flag(&help, long),
"security help must hide unsupported --{long}:\n{help}"
);
}
for long in [
"root",
"config",
"format",
"quiet",
"no-cache",
"threads",
"changed-since",
"diff-file",
"diff-stdin",
"workspace",
"changed-workspaces",
"ci",
"fail-on-issues",
"sarif-file",
"summary",
"output-file",
"max-file-size",
"explain",
"surface",
] {
assert!(
help_contains_long_flag(&help, long),
"security help must keep supported --{long}:\n{help}"
);
}
}
#[test]
fn security_help_detection_covers_subcommand_and_help_alias_forms() {
assert_eq!(
security_help_target(["security", "--help"]),
Some(SecurityHelpTarget::Parent)
);
assert_eq!(
security_help_target(["security", "-h"]),
Some(SecurityHelpTarget::Parent)
);
assert_eq!(
security_help_target(["--format", "json", "security", "--help"]),
Some(SecurityHelpTarget::Parent)
);
assert_eq!(
security_help_target(["help", "security"]),
Some(SecurityHelpTarget::Parent)
);
assert_eq!(
security_help_target(["security", "survivors", "--help"]),
Some(SecurityHelpTarget::Survivors)
);
assert_eq!(
security_help_target(["security", "survivors", "-h"]),
Some(SecurityHelpTarget::Survivors)
);
assert_eq!(
security_help_target(["help", "security", "survivors"]),
Some(SecurityHelpTarget::Survivors)
);
assert_eq!(
security_help_target(["security", "blind-spots", "--help"]),
Some(SecurityHelpTarget::BlindSpots)
);
assert_eq!(
security_help_target(["help", "security", "blind-spots"]),
Some(SecurityHelpTarget::BlindSpots)
);
assert_eq!(security_help_target(["health", "--help"]), None);
assert_eq!(security_help_target(["help", "health"]), None);
}
#[test]
fn security_unsupported_global_validator_matches_hidden_help_contract() {
for (argv, expected) in [
(vec!["fallow", "security", "--performance"], "--performance"),
(
vec!["fallow", "security", "--baseline", "base.json"],
"--baseline",
),
(
vec!["fallow", "security", "--dupes-mode", "weak"],
"--dupes-mode",
),
] {
let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
assert_eq!(unsupported_security_global(&cli), Some(expected));
}
let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
.expect("security --explain parses");
assert_eq!(unsupported_security_global(&explain), None);
}
#[test]
fn programmatic_common_options_track_analysis_affecting_cli_globals() {
use clap::CommandFactory;
let cli_flags: std::collections::BTreeSet<String> = Cli::command()
.get_arguments()
.filter(|arg| arg.is_global_set())
.filter_map(|arg| arg.get_long().map(str::to_owned))
.filter(|name| {
matches!(
name.as_str(),
"root"
| "config"
| "no-cache"
| "threads"
| "changed-since"
| "diff-file"
| "production"
| "workspace"
| "changed-workspaces"
| "explain"
)
})
.collect();
let programmatic_flags: std::collections::BTreeSet<String> =
fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
.iter()
.map(|flag| (*flag).to_owned())
.collect();
assert_eq!(programmatic_flags, cli_flags);
}
#[test]
fn dead_code_registry_filter_flags_are_exposed_by_clap() {
use clap::CommandFactory;
let cli = Cli::command();
let dead_code = cli
.get_subcommands()
.find(|command| command.get_name() == "dead-code")
.expect("dead-code subcommand is registered");
let cli_flags: std::collections::BTreeSet<String> = dead_code
.get_arguments()
.filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
.collect();
for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
assert!(
cli_flags.contains(*flag),
"registry filter flag {flag} is missing from dead-code clap args"
);
}
}
fn help_contains_long_flag(help: &str, long: &str) -> bool {
let flag = format!("--{long}");
help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
.any(|token| token == flag)
}
fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
let help = cmd.render_long_help().to_string();
for line in scan_forbidden(&help) {
violations.push((path.to_owned(), line));
}
let names: Vec<String> = cmd
.get_subcommands()
.map(|sub| sub.get_name().to_owned())
.collect();
for name in names {
if name == "help" {
continue;
}
if let Some(sub) = cmd.find_subcommand_mut(&name) {
let sub_path = format!("{path} {name}");
visit_help(sub, &sub_path, violations);
}
}
}
fn scan_forbidden(s: &str) -> Vec<String> {
let lower = s.to_ascii_lowercase();
let mut out = Vec::new();
for word in ["stub", "placeholder"] {
if let Some(idx) = find_whole_word(&lower, word) {
out.push(extract_line(s, idx));
}
}
if let Some(idx) = lower.find("not yet") {
out.push(extract_line(s, idx));
}
out
}
fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
let bytes = haystack.as_bytes();
let mut start = 0;
while let Some(rel) = haystack[start..].find(word) {
let abs = start + rel;
let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
let after_idx = abs + word.len();
let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
if before_ok && after_ok {
return Some(abs);
}
start = abs + word.len();
}
None
}
fn extract_line(s: &str, byte_idx: usize) -> String {
let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
s[line_start..line_end].trim().to_owned()
}
#[test]
fn emit_error_returns_given_exit_code() {
let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
assert_eq!(code, ExitCode::from(2));
}
fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
TelemetryRun {
workflow: telemetry::Workflow::Health,
output: fallow_config::OutputFormat::Json,
quiet: true,
start: std::time::Instant::now(),
context: telemetry::WorkflowContext {
run_scope: telemetry::RunScope::FullProject,
config_shape: telemetry::ConfigShape::Default,
output_destination: telemetry::OutputDestination::Stdout,
analysis_mode: mode,
},
}
}
#[test]
fn fallback_failure_reason_skips_success_and_findings() {
let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
}
#[test]
fn fallback_failure_reason_classifies_network_auth_and_analysis() {
let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
assert_eq!(
fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
Some(telemetry::FailureReason::Network),
);
assert_eq!(
fallback_failure_reason_for(&static_run, ExitCode::from(12)),
Some(telemetry::FailureReason::Auth),
);
assert_eq!(
fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
Some(telemetry::FailureReason::Auth),
);
assert_eq!(
fallback_failure_reason_for(&static_run, ExitCode::from(2)),
Some(telemetry::FailureReason::Analysis),
);
}
#[test]
fn bare_coverage_flags_parse_without_subcommand() {
let cli = Cli::try_parse_from([
"fallow",
"--coverage",
"coverage/coverage-final.json",
"--coverage-root",
"/ci/workspace",
])
.expect("bare combined coverage flags should parse");
assert!(cli.command.is_none());
assert_eq!(
cli.coverage.as_deref(),
Some(std::path::Path::new("coverage/coverage-final.json"))
);
assert_eq!(
cli.coverage_root.as_deref(),
Some(std::path::Path::new("/ci/workspace"))
);
}
#[test]
fn bare_coverage_before_subcommand_is_detectable() {
let cli = Cli::try_parse_from([
"fallow",
"--coverage",
"coverage/coverage-final.json",
"dead-code",
])
.expect("clap should parse pre-subcommand bare coverage for custom rejection");
assert!(cli.command.is_some());
assert!(cli_has_bare_coverage_input(&cli));
let message = bare_coverage_subcommand_error_message();
assert!(message.contains("bare combined-mode flags"));
assert!(message.contains("fallow health --coverage <coverage-final.json>"));
}
#[test]
fn subcommand_coverage_flag_keeps_regular_clap_error() {
let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
panic!("dead-code --coverage should fail to parse");
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn format_parsing_covers_all_variants() {
assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
assert!(matches!(
parse_format_arg("markdown"),
Some(Format::Markdown)
));
assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
assert!(matches!(
parse_format_arg("codeclimate"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse_format_arg("gitlab-codequality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse_format_arg("gitlab-code-quality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse_format_arg("pr-comment-github"),
Some(Format::PrCommentGithub)
));
assert!(matches!(
parse_format_arg("pr-comment-gitlab"),
Some(Format::PrCommentGitlab)
));
assert!(matches!(
parse_format_arg("review-github"),
Some(Format::ReviewGithub)
));
assert!(matches!(
parse_format_arg("review-gitlab"),
Some(Format::ReviewGitlab)
));
assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
assert!(parse_format_arg("xml").is_none());
assert!(parse_format_arg("").is_none());
}
#[test]
fn quiet_parsing_logic() {
let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
assert!(parse("1"));
assert!(parse("true"));
assert!(parse("TRUE"));
assert!(parse("True"));
assert!(!parse("0"));
assert!(!parse("false"));
assert!(!parse("yes"));
}
#[test]
fn tracing_filter_defaults_to_warn_without_env() {
assert_eq!(build_tracing_filter(None).to_string(), "warn");
}
#[test]
fn tracing_filter_respects_explicit_env_directives() {
assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
}
#[test]
fn tracing_filter_treats_empty_env_as_off() {
assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
}
}