#![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::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{CommandFactory, Parser, Subcommand};
mod api;
mod audit;
mod base_worktree;
mod baseline;
mod cache_notice;
mod check;
mod ci;
mod ci_template;
mod codeowners;
mod combined;
mod config;
mod coverage;
mod dupes;
mod error;
mod explain;
mod fix;
mod flags;
mod health;
mod health_types;
mod impact;
mod init;
mod inspect;
mod license;
mod list;
mod migrate;
mod output_dupes;
mod output_envelope;
mod path_util;
mod rayon_pool;
mod regression;
mod report;
mod runtime_support;
mod schema;
mod security;
mod setup_hooks;
mod signal;
mod task_matrix;
mod telemetry;
mod update_check;
mod validate;
mod vital_signs;
mod watch;
use check::{CheckOptions, IssueFilters, TraceOptions};
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, build_ownership_resolver, load_config, load_config_for_analysis,
};
const SECURITY_UNSUPPORTED_GLOBAL_LONGS: &[&str] = &[
"baseline",
"save-baseline",
"production",
"no-production",
"group-by",
"performance",
"explain-skipped",
"fail-on-regression",
"regression-baseline",
"save-regression-baseline",
"dupes-mode",
"dupes-threshold",
"dupes-min-tokens",
"dupes-min-lines",
"dupes-min-occurrences",
"dupes-skip-local",
"dupes-cross-language",
"dupes-ignore-imports",
"include-entry-exports",
];
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, and duplication
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 or jscpd 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
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)]
legacy_envelope: 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)]
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>,
},
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,
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<crate::health_types::FindingSeverity>,
#[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>,
},
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, 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>,
},
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::SecurityGateMode>,
#[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::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum HooksTargetArg {
Git,
Agent,
}
#[derive(clap::Subcommand)]
enum HooksCli {
Status,
Install {
#[arg(long, value_enum)]
target: HooksTargetArg,
#[arg(long)]
branch: Option<String>,
#[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,
},
Uninstall {
#[arg(long, value_enum)]
target: HooksTargetArg,
#[arg(long, value_enum)]
agent: Option<setup_hooks::HookAgentArg>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
user: bool,
},
}
#[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(Clone, Copy, clap::Subcommand)]
enum ImpactCli {
Enable,
Disable,
Default {
#[arg(value_enum)]
state: ToggleState,
},
Reset {
#[arg(long)]
all: bool,
},
Status,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum ToggleState {
On,
Off,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum ImpactSortCli {
Recent,
Resolved,
Contained,
Name,
}
impl ImpactSortCli {
const fn to_impact(self) -> impact::CrossRepoSort {
match self {
Self::Recent => impact::CrossRepoSort::Recent,
Self::Resolved => impact::CrossRepoSort::Resolved,
Self::Contained => impact::CrossRepoSort::Contained,
Self::Name => impact::CrossRepoSort::Name,
}
}
}
#[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)]
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(Clone, Copy, clap::ValueEnum)]
enum Format {
Human,
Json,
Sarif,
Compact,
Markdown,
#[value(
name = "codeclimate",
alias = "gitlab-codequality",
alias = "gitlab-code-quality"
)]
CodeClimate,
#[value(name = "pr-comment-github")]
PrCommentGithub,
#[value(name = "pr-comment-gitlab")]
PrCommentGitlab,
#[value(name = "review-github")]
ReviewGithub,
#[value(name = "review-gitlab")]
ReviewGitlab,
Badge,
}
#[derive(Subcommand)]
enum CiCli {
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(Clone, Copy, Debug, clap::ValueEnum)]
enum CiProviderArg {
Github,
Gitlab,
}
impl From<Format> for fallow_config::OutputFormat {
fn from(f: Format) -> Self {
match f {
Format::Human => Self::Human,
Format::Json => Self::Json,
Format::Sarif => Self::Sarif,
Format::Compact => Self::Compact,
Format::Markdown => Self::Markdown,
Format::CodeClimate => Self::CodeClimate,
Format::PrCommentGithub => Self::PrCommentGithub,
Format::PrCommentGitlab => Self::PrCommentGitlab,
Format::ReviewGithub => Self::ReviewGithub,
Format::ReviewGitlab => Self::ReviewGitlab,
Format::Badge => Self::Badge,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EffortFilter {
Low,
Medium,
High,
}
impl EffortFilter {
const fn to_estimate(self) -> health_types::EffortEstimate {
match self {
Self::Low => health_types::EffortEstimate::Low,
Self::Medium => health_types::EffortEstimate::Medium,
Self::High => health_types::EffortEstimate::High,
}
}
}
#[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 format_from_env() -> Option<Format> {
let val = std::env::var("FALLOW_FORMAT").ok()?;
parse_format_arg(&val)
}
fn parse_format_arg(value: &str) -> Option<Format> {
match value.to_lowercase().as_str() {
"json" => Some(Format::Json),
"human" => Some(Format::Human),
"sarif" => Some(Format::Sarif),
"compact" => Some(Format::Compact),
"markdown" | "md" => Some(Format::Markdown),
"codeclimate" | "gitlab-codequality" | "gitlab-code-quality" => Some(Format::CodeClimate),
"pr-comment-github" => Some(Format::PrCommentGithub),
"pr-comment-gitlab" => Some(Format::PrCommentGitlab),
"review-github" => Some(Format::ReviewGithub),
"review-gitlab" => Some(Format::ReviewGitlab),
"badge" => Some(Format::Badge),
_ => None,
}
}
fn quiet_from_env() -> bool {
std::env::var("FALLOW_QUIET").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn bool_from_env(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
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))
}
}
struct FormatConfig {
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
}
fn resolve_format(cli: &Cli) -> FormatConfig {
let cli_format_was_explicit = std::env::args()
.any(|a| a == "--format" || a == "--output" || a.starts_with("--format=") || a == "-f");
let format: Format = if cli_format_was_explicit {
cli.format
} else {
format_from_env().unwrap_or(cli.format)
};
let quiet = cli.quiet || quiet_from_env();
FormatConfig {
output: format.into(),
quiet,
cli_format_was_explicit,
}
}
fn build_tracing_filter(rust_log: Option<&str>) -> tracing_subscriber::EnvFilter {
use tracing_subscriber::filter::LevelFilter;
let builder = tracing_subscriber::EnvFilter::builder();
match rust_log.map(str::trim) {
Some("") => builder
.with_default_directive(LevelFilter::OFF.into())
.parse_lossy("off"),
Some(value) => builder
.with_default_directive(LevelFilter::OFF.into())
.parse_lossy(value),
None => builder
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy(""),
}
}
fn setup_tracing() {
let rust_log = std::env::var("RUST_LOG").ok();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(build_tracing_filter(rust_log.as_deref()))
.with_target(false)
.with_timer(tracing_subscriber::fmt::time::uptime())
.init();
}
fn validate_inputs(
cli: &Cli,
output: fallow_config::OutputFormat,
) -> Result<(PathBuf, usize), ExitCode> {
if matches!(&cli.command, Some(Command::Security { .. }))
&& let Some(flag) = unsupported_security_global(cli)
{
return Err(emit_known_failure(
&format!("{flag} is not valid with `fallow security`."),
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref config_path) = cli.config
&& let Some(s) = config_path.to_str()
&& let Err(e) = validate::validate_no_control_chars(s, "--config")
{
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref ws_patterns) = cli.workspace {
for ws in ws_patterns {
if let Err(e) = validate::validate_no_control_chars(ws, "--workspace") {
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
}
}
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-since")
{
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-workspaces")
{
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Validation,
));
}
if cli.workspace.is_some() && cli.changed_workspaces.is_some() {
return Err(emit_known_failure(
"--workspace and --changed-workspaces are mutually exclusive. \
Pick one: --workspace for explicit package names/globs, \
--changed-workspaces for git-derived monorepo CI scoping.",
2,
output,
telemetry::FailureReason::Validation,
));
}
let raw_root = if let Some(root) = cli.root.clone() {
root
} else {
std::env::current_dir().map_err(|err| {
emit_known_failure(
&format!("Failed to get current directory: {err}"),
2,
output,
telemetry::FailureReason::Config,
)
})?
};
let root = match validate::validate_root(&raw_root) {
Ok(r) => r,
Err(e) => {
return Err(emit_known_failure(
&e,
2,
output,
telemetry::FailureReason::Config,
));
}
};
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_known_failure(
&format!("invalid --changed-since: {e}"),
2,
output,
telemetry::FailureReason::Validation,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_known_failure(
&format!("invalid --changed-workspaces: {e}"),
2,
output,
telemetry::FailureReason::Validation,
));
}
let threads = cli
.threads
.unwrap_or_else(|| std::thread::available_parallelism().map_or(4, std::num::NonZero::get));
rayon_pool::configure_global_pool(threads);
Ok((root, threads))
}
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
}
}
fn apply_ci_defaults(
ci: bool,
mut fail_on_issues: bool,
output: fallow_config::OutputFormat,
quiet: bool,
cli_format_was_explicit: bool,
) -> (fallow_config::OutputFormat, bool, bool) {
if ci {
let ci_output = if !cli_format_was_explicit && format_from_env().is_none() {
fallow_config::OutputFormat::Sarif
} else {
output
};
fail_on_issues = true;
(ci_output, true, fail_on_issues)
} else {
(output, quiet, fail_on_issues)
}
}
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,
}
}
}
#[derive(Clone, Copy)]
struct ProductionModes {
dead_code: bool,
health: bool,
dupes: bool,
}
impl ProductionModes {
const fn for_analysis(self, analysis: fallow_config::ProductionAnalysis) -> bool {
match analysis {
fallow_config::ProductionAnalysis::DeadCode => self.dead_code,
fallow_config::ProductionAnalysis::Health => self.health,
fallow_config::ProductionAnalysis::Dupes => self.dupes,
}
}
}
fn load_config_production(
root: &std::path::Path,
config_path: Option<&PathBuf>,
output: fallow_config::OutputFormat,
) -> Result<fallow_config::ProductionConfig, ExitCode> {
let loaded = if let Some(path) = config_path {
fallow_config::FallowConfig::load(path)
.map(Some)
.map_err(|e| {
emit_error(
&format!("failed to load config '{}': {e}", path.display()),
2,
output,
)
})?
} else {
fallow_config::FallowConfig::find_and_load(root)
.map(|found| found.map(|(config, _)| config))
.map_err(|e| emit_error(&e, 2, output))?
};
Ok(match loaded {
Some(config) => config.production,
None => fallow_config::ProductionConfig::default(),
})
}
fn resolve_production_modes(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
production_dead_code: bool,
production_health: bool,
production_dupes: bool,
) -> Result<ProductionModes, ExitCode> {
let config = load_config_production(root, cli.config.as_ref(), output)?;
let env_global = bool_from_env("FALLOW_PRODUCTION");
let resolve_one =
|analysis: fallow_config::ProductionAnalysis, cli_specific: bool, env_name: &str| {
if cli.production || cli_specific {
true
} else if cli.no_production {
false
} else if let Some(value) = bool_from_env(env_name) {
value
} else if let Some(value) = env_global {
value
} else {
config.for_analysis(analysis)
}
};
Ok(ProductionModes {
dead_code: resolve_one(
fallow_config::ProductionAnalysis::DeadCode,
production_dead_code,
"FALLOW_PRODUCTION_DEAD_CODE",
),
health: resolve_one(
fallow_config::ProductionAnalysis::Health,
production_health,
"FALLOW_PRODUCTION_HEALTH",
),
dupes: resolve_one(
fallow_config::ProductionAnalysis::Dupes,
production_dupes,
"FALLOW_PRODUCTION_DUPES",
),
})
}
#[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_core::churn::set_spawn_hook(signal::scoped_child::output);
fallow_core::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 args_use_legacy_check_alias<I>(args: I) -> bool
where
I: IntoIterator<Item = String>,
{
let value_options = [
"-r",
"--root",
"-c",
"--config",
"-f",
"--format",
"--output",
"--threads",
"--changed-since",
"--base",
"--diff-file",
"--baseline",
"--parent-run",
"--save-baseline",
"-w",
"--workspace",
"--changed-workspaces",
"--group-by",
"--file",
"--sarif-file",
"--only",
"--skip",
"--dupes-mode",
"--dupes-threshold",
"--dupes-min-tokens",
"--dupes-min-lines",
"--dupes-min-occurrences",
"--dupes-skip-local",
"--dupes-cross-language",
"--dupes-ignore-imports",
"--save-snapshot",
"--regression-baseline",
"--tolerance",
"--save-regression-baseline",
];
let mut skip_next = false;
for arg in args.into_iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--" {
break;
}
if arg.starts_with('-') {
let option_name = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name);
if !arg.contains('=') && value_options.contains(&option_name) {
skip_next = true;
}
continue;
}
return arg == "check";
}
false
}
fn raw_args_use_legacy_check_alias() -> bool {
args_use_legacy_check_alias(std::env::args())
}
fn warn_legacy_check_alias_if_needed(used_legacy_check_alias: bool, quiet: bool) {
if used_legacy_check_alias && !quiet {
eprintln!("fallow: `check` is deprecated; use `dead-code` instead.");
}
}
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 used_legacy_check_alias = raw_args_use_legacy_check_alias();
let mut cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => return handle_cli_parse_error(&err),
};
warn_legacy_check_alias_if_needed(used_legacy_check_alias, cli.quiet);
output_envelope::set_legacy_envelope(cli.legacy_envelope);
runtime_support::set_max_file_size_override(cli.max_file_size);
if let Some(workspaces) = cli.workspace.as_ref()
&& !workspaces.is_empty()
{
report::ci::pr_comment::set_workspace_marker_from_list(workspaces);
}
if let Some(code) = run_schema_command_if_requested(&cli) {
return code;
}
let fmt = resolve_format(&cli);
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;
if let Err(code) = init_cli_diff_filter(&cli, &root, output, quiet) {
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Diff),
cli.parent_run.as_deref(),
);
}
if (cli.ci || cli.fail_on_issues || cli.sarif_file.is_some() || cli.output_file.is_some())
&& command_rejects_output_gate(cli.command.as_ref())
{
let code = emit_known_failure(
"--ci, --fail-on-issues, --sarif-file, and --output-file are only valid with dead-code, dupes, health, security, or bare invocation",
2,
output,
telemetry::FailureReason::Validation,
);
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
if let Some(message) = global_filter_error(&cli) {
let code = emit_known_failure(message, 2, output, telemetry::FailureReason::Validation);
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
let tolerance = match parse_cli_tolerance(&cli, output) {
Ok(tolerance) => tolerance,
Err(code) => {
return record_run_epilogue(
telemetry_run,
code,
Some(telemetry::FailureReason::Validation),
cli.parent_run.as_deref(),
);
}
};
let (save_regression_file, save_to_config) = regression_save_targets(&cli);
if let Some(path) = cli.output_file.as_deref()
&& let Err(code) = redirect_report_to_file(path, output)
{
return code;
}
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 = 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 code;
}
record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
}
#[derive(Clone, Copy)]
struct TelemetryRun {
workflow: telemetry::Workflow,
output: fallow_config::OutputFormat,
quiet: bool,
start: std::time::Instant,
context: telemetry::WorkflowContext,
}
fn record_run_epilogue(
run: TelemetryRun,
exit_code: ExitCode,
failure_reason: Option<telemetry::FailureReason>,
parent_run: Option<&str>,
) -> ExitCode {
let cache_notice_printed = cache_notice::maybe_print_created_notice();
telemetry::record_workflow(&telemetry::WorkflowRecord {
workflow: run.workflow,
output: run.output,
quiet: run.quiet,
elapsed: run.start.elapsed(),
exit_code,
failure_reason,
parent_run,
context: run.context,
});
if exit_code == ExitCode::SUCCESS {
let note_printed = telemetry::maybe_print_opt_in_note(run.output, run.quiet);
update_check::maybe_nudge(run.output, run.quiet, note_printed || cache_notice_printed);
}
exit_code
}
fn start_telemetry_run(cli: &Cli, fmt: &FormatConfig) -> TelemetryRun {
setup_tracing();
let run = TelemetryRun {
workflow: telemetry_workflow_for_command(cli.command.as_ref(), fmt.output),
output: fmt.output,
quiet: fmt.quiet,
start: std::time::Instant::now(),
context: telemetry_context_for_command(cli, cli.command.as_ref(), fmt.output),
};
output_envelope::set_telemetry_analysis_run_id(
matches!(fmt.output, fallow_config::OutputFormat::Json)
.then(telemetry::new_analysis_run_id),
);
telemetry::flush_spool_in_background();
run
}
fn telemetry_context_for_command(
cli: &Cli,
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::WorkflowContext {
telemetry::WorkflowContext {
run_scope: telemetry_run_scope_for_command(cli, command),
config_shape: telemetry_config_shape_for_cli(cli),
output_destination: telemetry_output_destination_for_command(cli, command, output),
analysis_mode: telemetry_analysis_mode_for_command(command),
}
}
fn telemetry_run_scope_for_command(cli: &Cli, command: Option<&Command>) -> telemetry::RunScope {
if command_is_file_scoped(command) {
return telemetry::RunScope::FileScoped;
}
if cli
.workspace
.as_ref()
.is_some_and(|workspaces| !workspaces.is_empty())
|| cli.changed_workspaces.is_some()
{
return telemetry::RunScope::WorkspaceScoped;
}
if cli.changed_since.is_some()
|| cli.diff_file.is_some()
|| cli.diff_stdin
|| matches!(command, Some(Command::Audit { .. }))
{
return telemetry::RunScope::ChangedOnly;
}
if command_runs_full_project_analysis(command) {
return telemetry::RunScope::FullProject;
}
telemetry::RunScope::Unknown
}
fn command_is_file_scoped(command: Option<&Command>) -> bool {
matches!(
command,
Some(Command::Check { file, .. } | Command::Security { file, .. }) if !file.is_empty()
)
}
fn command_runs_full_project_analysis(command: Option<&Command>) -> bool {
matches!(
command,
None | Some(
Command::Check { .. }
| Command::Dupes { .. }
| Command::Health { .. }
| Command::Flags { .. }
| Command::Security { .. }
| Command::Fix { .. }
| Command::Watch { .. },
)
)
}
fn telemetry_config_shape_for_cli(cli: &Cli) -> telemetry::ConfigShape {
if cli.config.is_some() {
telemetry::ConfigShape::CustomConfig
} else {
telemetry::ConfigShape::Unknown
}
}
fn telemetry_output_destination_for_command(
cli: &Cli,
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::OutputDestination {
if matches!(command, Some(Command::Ci { .. }))
|| matches!(
output,
fallow_config::OutputFormat::PrCommentGithub
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::ReviewGithub
| fallow_config::OutputFormat::CodeClimate
)
{
return telemetry::OutputDestination::CiComment;
}
if cli.output_file.is_some() || cli.sarif_file.is_some() {
return telemetry::OutputDestination::File;
}
telemetry::OutputDestination::Stdout
}
fn telemetry_analysis_mode_for_command(command: Option<&Command>) -> telemetry::AnalysisMode {
match command {
Some(Command::Security { .. }) => telemetry::AnalysisMode::Security,
Some(Command::Fix { .. }) => telemetry::AnalysisMode::Fix,
Some(Command::Health {
runtime_coverage: Some(_),
..
})
| Some(Command::Audit {
runtime_coverage: Some(_),
..
})
| Some(Command::Coverage { .. }) => telemetry::AnalysisMode::ProductionCoverage,
Some(Command::Health {
coverage: Some(_), ..
})
| Some(Command::Audit {
coverage: Some(_), ..
}) => telemetry::AnalysisMode::RuntimeCoverage,
None
| Some(
Command::Check { .. }
| Command::Dupes { .. }
| Command::Health { .. }
| Command::Audit { .. }
| Command::Flags { .. }
| Command::Watch { .. },
) => telemetry::AnalysisMode::Static,
_ => telemetry::AnalysisMode::Unknown,
}
}
fn handle_cli_parse_error(err: &clap::Error) -> ExitCode {
let exit_code = u8::try_from(err.exit_code()).unwrap_or(2);
if err.kind() == clap::error::ErrorKind::DisplayHelp
&& let Some(target) = security_help_target(std::env::args_os().skip(1))
{
print!("{}", render_security_help(target));
return ExitCode::SUCCESS;
}
if matches!(
parse_error_output_format(std::env::args_os().skip(1)),
fallow_config::OutputFormat::Json
) {
return emit_error(
err.to_string().trim(),
exit_code,
fallow_config::OutputFormat::Json,
);
}
let _ = err.print();
ExitCode::from(exit_code)
}
fn parse_error_output_format<I, S>(args: I) -> fallow_config::OutputFormat
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<String> = args
.into_iter()
.map(|arg| arg.as_ref().to_string_lossy().into_owned())
.collect();
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if matches!(arg.as_str(), "--format" | "--output" | "-f") {
return iter
.next()
.and_then(|value| parse_format_arg(value))
.unwrap_or(Format::Human)
.into();
}
let short_format_value = if arg.starts_with("--") {
None
} else {
arg.strip_prefix("-f")
};
if let Some(value) = arg
.strip_prefix("--format=")
.or_else(|| arg.strip_prefix("--output="))
.or(short_format_value)
.filter(|value| !value.is_empty())
{
return parse_format_arg(value).unwrap_or(Format::Human).into();
}
}
format_from_env().unwrap_or(Format::Human).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecurityHelpTarget {
Parent,
Survivors,
BlindSpots,
}
fn security_help_target<I, S>(args: I) -> Option<SecurityHelpTarget>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<String> = args
.into_iter()
.map(|arg| arg.as_ref().to_string_lossy().into_owned())
.collect();
if args.first().is_some_and(|arg| arg == "help") {
return match args.get(1).map(String::as_str) {
Some("security") if args.len() == 2 => Some(SecurityHelpTarget::Parent),
Some("security") if args.get(2).is_some_and(|arg| arg == "survivors") => {
Some(SecurityHelpTarget::Survivors)
}
Some("security") if args.get(2).is_some_and(|arg| arg == "blind-spots") => {
Some(SecurityHelpTarget::BlindSpots)
}
_ => None,
};
}
let mut saw_security = false;
let mut security_subcommand = None;
for arg in args {
if arg == "security" {
saw_security = true;
continue;
}
if saw_security && is_security_subcommand(&arg) {
security_subcommand = Some(arg);
continue;
}
if saw_security && matches!(arg.as_str(), "--help" | "-h") {
return match security_subcommand.as_deref() {
Some("survivors") => Some(SecurityHelpTarget::Survivors),
Some("blind-spots") => Some(SecurityHelpTarget::BlindSpots),
Some("help") => None,
_ => Some(SecurityHelpTarget::Parent),
};
}
}
None
}
fn is_security_subcommand(arg: &str) -> bool {
matches!(arg, "survivors" | "blind-spots" | "help")
}
fn render_security_help(target: SecurityHelpTarget) -> String {
match target {
SecurityHelpTarget::Parent => render_security_parent_help(),
SecurityHelpTarget::Survivors => render_security_survivors_help(),
SecurityHelpTarget::BlindSpots => render_security_blind_spots_help(),
}
}
fn render_security_parent_help() -> String {
let mut root = Cli::command().mut_args(|arg| {
if arg.get_long().is_some_and(security_unsupported_global_long) {
arg.hide(true)
} else {
arg
}
});
match root.try_get_matches_from_mut(["fallow", "security", "--help"]) {
Ok(_) => String::new(),
Err(err) => err.to_string(),
}
}
fn render_security_survivors_help() -> String {
"\
Render verifier-retained survivor candidates from fallow output plus verifier verdicts.
Usage: fallow security survivors --candidates <PATH> --verdicts <PATH> [OPTIONS]
Options:
--candidates <PATH> Raw `fallow security --format json` candidate output
--verdicts <PATH> Verifier verdict JSON file
--require-verdict-for-each-candidate Fail when any candidate has no matching verdict
-f, --format <FORMAT> Output format: human or json [default: human]
-o, --output-file <PATH> Write the report to a file instead of stdout
-h, --help Print help
Verdict JSON:
[{\"schema_version\":\"fallow-security-verdict/v1\",\"finding_id\":\"sec-a\",\"verdict\":\"survivor\"}]
Repo-local docs: docs/security-agent-verification.md
"
.to_owned()
}
fn render_security_blind_spots_help() -> String {
"\
Group unresolved security callees into actionable blind-spot output.
Usage: fallow security blind-spots [OPTIONS]
Options:
-r, --root <ROOT> Project root directory
-c, --config <CONFIG> Path to config file
-f, --format <FORMAT> Output format: human or json [default: human]
-q, --quiet Suppress progress output
--no-cache Disable incremental caching
--threads <THREADS> Number of parser threads
--changed-since <REF> Scope analysis to files changed since this git ref
--diff-file <PATH> Unified diff for line-level scoping
--diff-stdin Read the unified diff from stdin
--file <PATH> Scope diagnostics to selected files
-w, --workspace <WORKSPACE> Scope output to selected workspaces
--changed-workspaces <REF> Scope output to workspaces touched since this git ref
-o, --output-file <PATH> Write the report to a file instead of stdout
-h, --help Print help
"
.to_owned()
}
fn security_unsupported_global_long(long: &str) -> bool {
SECURITY_UNSUPPORTED_GLOBAL_LONGS.contains(&long)
}
fn cli_has_bare_coverage_input(cli: &Cli) -> bool {
cli.coverage.is_some() || cli.coverage_root.is_some()
}
fn bare_coverage_subcommand_error_message() -> &'static str {
"`--coverage` and `--coverage-root` are bare combined-mode flags. Use `fallow health --coverage <coverage-final.json>` for standalone health analysis, or omit the subcommand to run combined mode."
}
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 command_rejects_output_gate(command: Option<&Command>) -> bool {
matches!(
command,
Some(
Command::Init { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::RulePackSchema
| Command::Schema
| Command::Explain { .. }
| Command::CiTemplate { .. }
| Command::Config { .. }
| Command::Ci { .. }
| Command::List { .. }
| Command::Inspect { .. }
| Command::Flags { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Coverage { .. }
| Command::Hooks { .. }
| Command::SetupHooks { .. }
)
)
}
fn global_filter_error(cli: &Cli) -> Option<&'static str> {
if (!cli.only.is_empty() || !cli.skip.is_empty()) && cli.command.is_some() {
return Some("--only and --skip can only be used without a subcommand");
}
if (cli.production_dead_code || cli.production_health || cli.production_dupes)
&& cli.command.is_some()
{
return Some(
"--production-dead-code, --production-health, and --production-dupes can only be used without a subcommand. For audit, pass them after `audit`",
);
}
if !cli.only.is_empty() && !cli.skip.is_empty() {
return Some("--only and --skip are mutually exclusive");
}
None
}
fn parse_cli_tolerance(
cli: &Cli,
output: fallow_config::OutputFormat,
) -> Result<regression::Tolerance, ExitCode> {
regression::Tolerance::parse(&cli.tolerance).map_err(|e| {
emit_known_failure(
&format!("invalid --tolerance: {e}"),
2,
output,
telemetry::FailureReason::Validation,
)
})
}
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 init_cli_diff_filter(
cli: &Cli,
root: &std::path::Path,
output: fallow_config::OutputFormat,
quiet: bool,
) -> Result<(), ExitCode> {
let diff_source = report::ci::diff_filter::resolve_diff_source(
cli.diff_file.as_deref(),
cli.diff_stdin,
root,
)
.map_err(|msg| emit_known_failure(&msg, 2, output, telemetry::FailureReason::Diff))?;
if diff_source.is_some() && cli.changed_since.is_some() && !quiet {
eprintln!(
"fallow: --diff-file precedes --changed-since for line-level \
filtering; --changed-since still scopes file discovery. Drop \
one of them to disable this combination."
);
}
let suppress_warnings = quiet
&& matches!(
diff_source,
Some(report::ci::diff_filter::DiffSource::EnvVar(_)) | None
);
let _ = report::ci::diff_filter::init_shared_diff(diff_source.as_ref(), suppress_warnings);
Ok(())
}
fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
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,
};
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,
run_dupes,
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 {
Command::Check {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
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,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
policy_violations,
stale_suppressions,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
include_dupes,
trace,
trace_file,
trace_dependency,
top,
file,
} => dispatch_check(
dispatch,
&CheckDispatchArgs {
filters: IssueFilters {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
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,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
policy_violations,
stale_suppressions,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
invalid_client_exports: false,
mixed_client_server_barrels: false,
misplaced_directives: false,
route_collisions: false,
dynamic_segment_name_conflicts: false,
},
trace_opts: TraceOptions {
trace_export: trace,
trace_file,
trace_dependency,
performance: cli.performance,
},
include_dupes,
top,
file,
},
),
Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
Command::Inspect { file, symbol } => dispatch_inspect_command(dispatch, file, symbol),
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::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::Impact {
subcommand,
all,
sort,
limit,
} => dispatch_impact(root, quiet, output, subcommand, 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_inspect_command(
dispatch: &DispatchContext<'_>,
file: Option<String>,
symbol: Option<String>,
) -> 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,
})
}
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 cli = dispatch.cli;
let root = dispatch.root;
let threads = dispatch.threads;
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(SecuritySubcommand::Survivors {
candidates,
verdicts,
require_verdict_for_each_candidate,
}) = &subcommand
{
if let Some(code) = validate_security_survivors_flags(&derived_flags) {
return code;
}
return security::run_survivors(&security::SecuritySurvivorsOptions {
output,
candidates,
verdicts,
require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
});
}
let mut scoped_files = file.clone();
if let Some(SecuritySubcommand::BlindSpots {
file: blind_spot_files,
}) = &subcommand
{
scoped_files.extend(blind_spot_files.iter().cloned());
}
let opts = security::SecurityOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
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: scoped_files.as_slice(),
surface,
gate,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
explain: cli.explain,
};
if matches!(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)
}
}
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;
dispatch_health(
dispatch,
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,
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,
},
)
}
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,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
} = command
else {
unreachable!("audit dispatcher only handles audit commands");
};
dispatch_audit(
dispatch,
&AuditDispatchArgs {
production_dead_code,
production_health,
production_dupes,
dead_code_baseline,
health_baseline,
dupes_baseline,
max_crap,
coverage,
coverage_root,
gate,
runtime_coverage,
min_invocations_hot,
gate_marker,
},
)
}
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_impact(
root: &std::path::Path,
quiet: bool,
output: fallow_config::OutputFormat,
subcommand: Option<ImpactCli>,
all: bool,
sort: ImpactSortCli,
limit: Option<usize>,
) -> ExitCode {
if all {
if subcommand.is_some() {
return emit_known_failure(
"`fallow impact --all` is a read-only cross-repo view and cannot be combined \
with a subcommand (enable/disable/default/reset)",
2,
output,
telemetry::FailureReason::Validation,
);
}
return render_impact_all(quiet, output, sort, limit);
}
match subcommand {
Some(ImpactCli::Enable) => {
let newly = impact::enable(root);
if !quiet {
if newly {
println!(
"Fallow Impact enabled for this project. Each `fallow audit` / pre-commit \
gate run is recorded in your user config dir (never written into the \
repo, never uploaded)."
);
println!(
"Tip: run `fallow init --hooks` (or add `--gate-marker pre-commit` to \
your existing hook's `fallow audit` line) so blocked-then-fixed \
commits are recorded as contained."
);
} else {
println!("Fallow Impact is already enabled.");
}
}
ExitCode::SUCCESS
}
Some(ImpactCli::Disable) => {
let was_enabled = impact::disable(root);
if !quiet {
println!(
"{}",
if was_enabled {
"Fallow Impact disabled. Existing history is retained."
} else {
"Fallow Impact was already disabled."
}
);
}
ExitCode::SUCCESS
}
Some(ImpactCli::Default { state }) => {
let on = matches!(state, ToggleState::On);
let changed = impact::set_global_default(on);
if !quiet {
let verb = if on { "on" } else { "off" };
let body = if on {
"New projects now record Impact by default. A per-project `fallow impact \
disable` still opts that repo out."
} else {
"New projects no longer record by default; run `fallow impact enable` per \
project to opt in."
};
if changed {
println!("Fallow Impact default set to {verb}. {body}");
} else {
println!("Fallow Impact default was already {verb}.");
}
}
ExitCode::SUCCESS
}
Some(ImpactCli::Reset { all }) => {
if all {
let removed = impact::reset_all();
if !quiet {
println!(
"{}",
if removed {
"Removed all Fallow Impact history."
} else {
"No Fallow Impact history to remove."
}
);
}
} else {
let removed = impact::reset(root);
if !quiet {
println!(
"{}",
if removed {
"Removed this project's Fallow Impact history."
} else {
"No Fallow Impact history for this project."
}
);
}
}
ExitCode::SUCCESS
}
Some(ImpactCli::Status) | None => render_impact_status(root, quiet, output),
}
}
fn render_impact_status(
root: &std::path::Path,
quiet: bool,
output: fallow_config::OutputFormat,
) -> ExitCode {
let store = impact::load(root);
let report = impact::build_report(&store);
let is_human = matches!(output, fallow_config::OutputFormat::Human);
let rendered = match output {
fallow_config::OutputFormat::Json => impact::render_json(&report),
fallow_config::OutputFormat::Markdown => impact::render_markdown(&report),
fallow_config::OutputFormat::Human => impact::render_human(&report),
fallow_config::OutputFormat::Sarif
| fallow_config::OutputFormat::Compact
| fallow_config::OutputFormat::CodeClimate
| fallow_config::OutputFormat::PrCommentGithub
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::ReviewGithub
| fallow_config::OutputFormat::ReviewGitlab
| fallow_config::OutputFormat::Badge => {
return emit_known_failure(
"impact supports human, json, and markdown output",
2,
output,
telemetry::FailureReason::UnsupportedFormat,
);
}
};
println!("{rendered}");
if is_human && !quiet {
println!(" Store key: {}", impact::resolved_project_key(root));
match impact::resolved_store_path(root) {
Some(path) => println!(" Store file: {}", path.display()),
None => println!(" Store file: (no user config dir resolved; not persisted)"),
}
}
ExitCode::SUCCESS
}
fn render_impact_all(
quiet: bool,
output: fallow_config::OutputFormat,
sort: ImpactSortCli,
limit: Option<usize>,
) -> ExitCode {
let report = impact::aggregate(sort.to_impact());
let is_human = matches!(output, fallow_config::OutputFormat::Human);
let rendered = match output {
fallow_config::OutputFormat::Json => impact::render_cross_repo_json(&report),
fallow_config::OutputFormat::Markdown => impact::render_cross_repo_markdown(&report),
fallow_config::OutputFormat::Human => impact::render_cross_repo_human(&report, limit),
fallow_config::OutputFormat::Sarif
| fallow_config::OutputFormat::Compact
| fallow_config::OutputFormat::CodeClimate
| fallow_config::OutputFormat::PrCommentGithub
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::ReviewGithub
| fallow_config::OutputFormat::ReviewGitlab
| fallow_config::OutputFormat::Badge => {
return emit_known_failure(
"impact --all supports human, json, and markdown output",
2,
output,
telemetry::FailureReason::UnsupportedFormat,
);
}
};
println!("{rendered}");
if is_human
&& !quiet
&& let Some(dir) = impact::store_dir()
{
println!(" Stores: {}", dir.display());
}
ExitCode::SUCCESS
}
fn telemetry_workflow_for_command(
command: Option<&Command>,
output: fallow_config::OutputFormat,
) -> telemetry::Workflow {
match command {
None | Some(Command::Flags { .. } | Command::Watch { .. }) => {
telemetry::Workflow::CodeQualityReview
}
Some(Command::Check { .. }) => telemetry::Workflow::DeadCode,
Some(Command::Dupes { .. }) => telemetry::Workflow::Dupes,
Some(Command::Health { .. }) => telemetry::Workflow::Health,
Some(Command::Audit { .. }) => telemetry::Workflow::Audit,
Some(Command::Inspect { .. }) => telemetry::Workflow::ProjectInventory,
Some(Command::Ci { .. }) => match output {
fallow_config::OutputFormat::ReviewGitlab
| fallow_config::OutputFormat::PrCommentGitlab
| fallow_config::OutputFormat::CodeClimate => telemetry::Workflow::GitlabCi,
_ => telemetry::Workflow::GithubAction,
},
Some(Command::Coverage { .. }) => telemetry::Workflow::RuntimeCoverageSetup,
Some(Command::Impact { .. }) => telemetry::Workflow::Impact,
Some(Command::Security { .. }) => telemetry::Workflow::Security,
Some(Command::Fix { .. }) => telemetry::Workflow::Fix,
Some(Command::Explain { .. }) => telemetry::Workflow::Explain,
Some(Command::List { .. } | Command::Workspaces | Command::Schema) => {
telemetry::Workflow::ProjectInventory
}
Some(Command::License { .. }) => telemetry::Workflow::License,
Some(
Command::Init { .. }
| Command::Hooks { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::RulePackSchema
| Command::Config { .. }
| Command::CiTemplate { .. }
| Command::Migrate { .. }
| Command::Telemetry { .. }
| Command::SetupHooks { .. },
) => telemetry::Workflow::Setup,
}
}
fn run_hooks_command(
root: &std::path::Path,
subcommand: HooksCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
match subcommand {
HooksCli::Status => setup_hooks::run_hooks_status(root, output),
HooksCli::Install {
target: HooksTargetArg::Git,
branch,
agent,
dry_run,
force,
user,
gitignore_claude,
} => {
if agent.is_some() || user || gitignore_claude {
return emit_error(
"--agent, --user, and --gitignore-claude are only valid with `fallow hooks install --target agent`",
2,
output,
);
}
init::run_git_hooks_install(&init::GitHooksInstallOptions {
root,
branch: branch.as_deref(),
dry_run,
force,
})
}
HooksCli::Install {
target: HooksTargetArg::Agent,
branch,
agent,
dry_run,
force,
user,
gitignore_claude,
} => {
if branch.is_some() {
return emit_error(
"--branch is only valid with `fallow hooks install --target git`",
2,
output,
);
}
setup_hooks::run_setup_hooks_with_label(
&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall: false,
},
"fallow hooks install --target agent",
)
}
HooksCli::Uninstall {
target: HooksTargetArg::Git,
agent,
dry_run,
force,
user,
} => {
if agent.is_some() || user {
return emit_error(
"--agent and --user are only valid with `fallow hooks uninstall --target agent`",
2,
output,
);
}
init::run_git_hooks_uninstall(&init::GitHooksUninstallOptions {
root,
dry_run,
force,
})
}
HooksCli::Uninstall {
target: HooksTargetArg::Agent,
agent,
dry_run,
force,
user,
} => setup_hooks::run_setup_hooks_with_label(
&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude: false,
uninstall: true,
},
"fallow hooks uninstall --target agent",
),
}
}
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 {
CiCli::ReconcileReview {
provider,
pr,
mr,
envelope,
repo,
project_id,
api_url,
dry_run,
} => ci::CiCommand::ReconcileReview {
provider: match provider {
CiProviderArg::Github => ci::CiProvider::Github,
CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
},
target: pr.or(mr),
envelope,
repo,
project_id,
api_url,
dry_run,
},
}
}
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,
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,
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>,
gate: Option<AuditGateArg>,
runtime_coverage: Option<PathBuf>,
min_invocations_hot: u64,
gate_marker: Option<String>,
}
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,
output,
cli.no_cache,
dispatch.threads,
cli.production,
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 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,
runtime_coverage: args.runtime_coverage.as_deref(),
min_invocations_hot: args.min_invocations_hot,
},
args.gate_marker.as_deref(),
)
}
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<health_types::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,
dispatch.output,
dispatch.cli.no_cache,
dispatch.threads,
dispatch.cli.production,
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<health_types::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<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 threads = dispatch.threads;
let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
let HealthDispatchArgs {
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,
} = args;
if let Err(code) =
validate_health_report_only_gate(report_only, min_score, min_severity, output)
{
return code;
}
let targets = targets || effort.is_some();
let sections = effective_health_sections(&EffectiveHealthSectionInput {
output,
complexity,
file_scores,
coverage_gaps,
hotspots,
targets,
css,
score,
min_score,
save_snapshot,
trend,
});
let runtime_coverage = match resolve_runtime_coverage_options(
runtime_coverage,
min_invocations_hot,
min_observation_volume,
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, coverage, coverage_root) {
Ok(inputs) => inputs,
Err(code) => return code,
};
health::run_health(&HealthOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
max_cyclomatic,
max_cognitive,
max_crap,
top,
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,
complexity_breakdown,
file_scores: sections.file_scores,
coverage_gaps: sections.coverage_gaps,
config_activates_coverage_gaps: !sections.any_section,
hotspots: sections.hotspots,
ownership: ownership && sections.hotspots,
ownership_emails,
targets: sections.targets,
css: sections.css,
force_full: sections.force_full,
score_only_output: sections.score_only_output,
enforce_coverage_gap_gate: true,
effort: effort.map(EffortFilter::to_estimate),
score: sections.score,
min_score,
min_severity,
report_only,
since,
min_commits,
explain: cli.explain,
summary: cli.summary,
save_snapshot: save_snapshot.map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
trend,
group_by: cli.group_by,
coverage: coverage_inputs.coverage.as_deref(),
coverage_root: coverage_inputs.coverage_root.as_deref(),
performance: cli.performance,
runtime_coverage,
churn_file: cli.churn_file.as_deref(),
})
}
struct EffectiveHealthSectionInput<'a> {
output: fallow_config::OutputFormat,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
targets: bool,
css: bool,
score: bool,
min_score: Option<f64>,
save_snapshot: Option<&'a Option<String>>,
trend: bool,
}
struct EffectiveHealthSections {
any_section: bool,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
targets: bool,
css: bool,
score: bool,
force_full: bool,
score_only_output: bool,
}
fn effective_health_sections(input: &EffectiveHealthSectionInput<'_>) -> EffectiveHealthSections {
let score = input.score
|| input.min_score.is_some()
|| input.trend
|| matches!(input.output, fallow_config::OutputFormat::Badge);
let snapshot_requested = input.save_snapshot.is_some();
let any_section = input.complexity
|| input.file_scores
|| input.coverage_gaps
|| input.hotspots
|| input.targets
|| score;
let eff_score = if any_section { score } else { true } || snapshot_requested;
let force_full = snapshot_requested || eff_score;
EffectiveHealthSections {
any_section,
complexity: if any_section { input.complexity } else { true },
file_scores: if any_section { input.file_scores } else { true } || force_full,
coverage_gaps: if any_section {
input.coverage_gaps
} else {
false
},
hotspots: if any_section { input.hotspots } else { true }
|| snapshot_requested
|| input.trend,
targets: if any_section { input.targets } else { true },
css: input.css,
score: eff_score,
force_full,
score_only_output: is_health_score_only_output(input, score),
}
}
fn is_health_score_only_output(input: &EffectiveHealthSectionInput<'_>, score: bool) -> bool {
score
&& !input.complexity
&& !input.file_scores
&& !input.coverage_gaps
&& !input.hotspots
&& !input.targets
&& !input.trend
}
#[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",
"legacy-envelope",
"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"
| "legacy-envelope"
)
})
.collect();
let programmatic_flags: std::collections::BTreeSet<String> =
fallow_cli::programmatic::COMMON_ANALYSIS_OPTION_FLAGS
.iter()
.map(|flag| (*flag).to_owned())
.collect();
assert_eq!(programmatic_flags, cli_flags);
}
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));
}
#[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 legacy_check_alias_detection_ignores_option_values() {
assert!(args_use_legacy_check_alias(vec![
"fallow".to_string(),
"check".to_string(),
"--summary".to_string(),
]));
assert!(!args_use_legacy_check_alias(vec![
"fallow".to_string(),
"--root".to_string(),
"check".to_string(),
"dead-code".to_string(),
]));
assert!(!args_use_legacy_check_alias(vec![
"fallow".to_string(),
"dead-code".to_string(),
"--file".to_string(),
"check".to_string(),
]));
}
#[test]
fn format_parsing_covers_all_variants() {
let parse = |s: &str| -> Option<Format> {
match s.to_lowercase().as_str() {
"json" => Some(Format::Json),
"human" => Some(Format::Human),
"sarif" => Some(Format::Sarif),
"compact" => Some(Format::Compact),
"markdown" | "md" => Some(Format::Markdown),
"codeclimate" | "gitlab-codequality" | "gitlab-code-quality" => {
Some(Format::CodeClimate)
}
"pr-comment-github" => Some(Format::PrCommentGithub),
"pr-comment-gitlab" => Some(Format::PrCommentGitlab),
"review-github" => Some(Format::ReviewGithub),
"review-gitlab" => Some(Format::ReviewGitlab),
"badge" => Some(Format::Badge),
_ => None,
}
};
assert!(matches!(parse("json"), Some(Format::Json)));
assert!(matches!(parse("JSON"), Some(Format::Json)));
assert!(matches!(parse("human"), Some(Format::Human)));
assert!(matches!(parse("sarif"), Some(Format::Sarif)));
assert!(matches!(parse("compact"), Some(Format::Compact)));
assert!(matches!(parse("markdown"), Some(Format::Markdown)));
assert!(matches!(parse("md"), Some(Format::Markdown)));
assert!(matches!(parse("codeclimate"), Some(Format::CodeClimate)));
assert!(matches!(
parse("gitlab-codequality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse("gitlab-code-quality"),
Some(Format::CodeClimate)
));
assert!(matches!(
parse("pr-comment-github"),
Some(Format::PrCommentGithub)
));
assert!(matches!(
parse("pr-comment-gitlab"),
Some(Format::PrCommentGitlab)
));
assert!(matches!(parse("review-github"), Some(Format::ReviewGithub)));
assert!(matches!(parse("review-gitlab"), Some(Format::ReviewGitlab)));
assert!(matches!(parse("badge"), Some(Format::Badge)));
assert!(parse("xml").is_none());
assert!(parse("").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");
}
}