#![expect(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI binary produces intentional terminal output"
)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
mod api;
mod audit;
mod baseline;
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 init;
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 setup_hooks;
mod signal;
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::{build_ownership_resolver, load_config, load_config_for_analysis};
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
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
workspaces Show monorepo workspace discovery diagnostics
explain Explain one issue type without running analysis
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
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
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.";
#[derive(Parser)]
#[command(
name = "fallow",
about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
version,
help_template = TOP_LEVEL_HELP_TEMPLATE,
after_help = TOP_LEVEL_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[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, global = true)]
baseline: Option<PathBuf>,
#[arg(long, global = true)]
save_baseline: Option<PathBuf>,
#[arg(long, global = true)]
production: bool,
#[arg(long = "production-dead-code")]
production_dead_code: bool,
#[arg(long = "production-health")]
production_health: bool,
#[arg(long = "production-dupes")]
production_dupes: bool,
#[arg(short, long, global = true, value_delimiter = ',')]
workspace: Option<Vec<String>>,
#[arg(long, global = true, value_name = "REF")]
changed_workspaces: Option<String>,
#[arg(long, global = true)]
group_by: Option<GroupBy>,
#[arg(long, global = true)]
performance: bool,
#[arg(long, global = true)]
explain: bool,
#[arg(long, global = true)]
explain_skipped: bool,
#[arg(long, global = true)]
summary: bool,
#[arg(long, global = true)]
ci: bool,
#[arg(long, global = true)]
fail_on_issues: bool,
#[arg(long, global = true, value_name = "PATH")]
sarif_file: Option<PathBuf>,
#[arg(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)]
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, 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)]
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)]
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,
},
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)]
hooks: bool,
#[arg(long, requires = "hooks")]
branch: Option<String>,
},
Hooks {
#[command(subcommand)]
subcommand: HooksCli,
},
Ci {
#[command(subcommand)]
subcommand: CiCli,
},
ConfigSchema,
PluginSchema,
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)]
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)]
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, 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, 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,
},
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,
},
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(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum HooksTargetArg {
Git,
Agent,
}
#[derive(clap::Subcommand)]
enum HooksCli {
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(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,
},
}
#[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,
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::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()?;
match val.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 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_error(&e, 2, output));
}
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_error(&e, 2, output));
}
}
}
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-since")
{
return Err(emit_error(&e, 2, output));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_no_control_chars(git_ref, "--changed-workspaces")
{
return Err(emit_error(&e, 2, output));
}
if cli.workspace.is_some() && cli.changed_workspaces.is_some() {
return Err(emit_error(
"--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,
));
}
let raw_root = cli
.root
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
let root = match validate::validate_root(&raw_root) {
Ok(r) => r,
Err(e) => {
return Err(emit_error(&e, 2, output));
}
};
if let Some(ref git_ref) = cli.changed_since
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_error(
&format!("invalid --changed-since: {e}"),
2,
output,
));
}
if let Some(ref git_ref) = cli.changed_workspaces
&& let Err(e) = validate::validate_git_ref(git_ref)
{
return Err(emit_error(
&format!("invalid --changed-workspaces: {e}"),
2,
output,
));
}
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 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 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 main() -> ExitCode {
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}");
}
fallow_core::churn::set_spawn_hook(signal::scoped_child::output);
fallow_core::changed_files::set_spawn_hook(signal::scoped_child::output);
if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
return signal_test_helper();
}
let mut cli = Cli::parse();
if let Some(workspaces) = cli.workspace.as_ref()
&& !workspaces.is_empty()
{
report::ci::pr_comment::set_workspace_marker_from_list(workspaces);
}
if matches!(cli.command, Some(Command::Schema)) {
return schema::run_schema();
}
if matches!(cli.command, Some(Command::ConfigSchema)) {
return init::run_config_schema();
}
if matches!(cli.command, Some(Command::PluginSchema)) {
return init::run_plugin_schema();
}
let fmt = resolve_format(&cli);
setup_tracing();
let (root, threads) = match validate_inputs(&cli, fmt.output) {
Ok(v) => v,
Err(code) => return code,
};
let FormatConfig {
output,
quiet,
cli_format_was_explicit,
} = fmt;
let diff_source = match report::ci::diff_filter::resolve_diff_source(
cli.diff_file.as_deref(),
cli.diff_stdin,
&root,
) {
Ok(src) => src,
Err(msg) => return emit_error(&msg, 2, output),
};
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);
if (cli.ci || cli.fail_on_issues || cli.sarif_file.is_some())
&& matches!(
cli.command,
Some(
Command::Init { .. }
| Command::ConfigSchema
| Command::PluginSchema
| Command::Schema
| Command::Explain { .. }
| Command::CiTemplate { .. }
| Command::Config { .. }
| Command::Ci { .. }
| Command::List { .. }
| Command::Flags { .. }
| Command::Migrate { .. }
| Command::License { .. }
| Command::Coverage { .. }
| Command::Hooks { .. }
| Command::SetupHooks { .. }
)
)
{
return emit_error(
"--ci, --fail-on-issues, and --sarif-file are only valid with dead-code, dupes, health, or bare invocation",
2,
output,
);
}
if (!cli.only.is_empty() || !cli.skip.is_empty()) && cli.command.is_some() {
return emit_error(
"--only and --skip can only be used without a subcommand",
2,
output,
);
}
if (cli.production_dead_code || cli.production_health || cli.production_dupes)
&& cli.command.is_some()
{
return emit_error(
"--production-dead-code, --production-health, and --production-dupes can only be used without a subcommand. For audit, pass them after `audit`",
2,
output,
);
}
if !cli.only.is_empty() && !cli.skip.is_empty() {
return emit_error("--only and --skip are mutually exclusive", 2, output);
}
let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
Ok(t) => t,
Err(e) => return emit_error(&format!("invalid --tolerance: {e}"), 2, output),
};
let save_regression_file: Option<std::path::PathBuf> =
cli.save_regression_baseline.as_ref().and_then(|opt| {
opt.as_ref()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
});
let save_to_config = cli.save_regression_baseline.is_some() && save_regression_file.is_none();
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,
};
match command {
None => dispatch_bare_command(&dispatch),
Some(cmd) => dispatch_subcommand(cmd, &dispatch),
}
}
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,
};
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(),
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,
score: cli.score || cli.trend,
trend: cli.trend,
save_snapshot: cli.save_snapshot.as_ref(),
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(),
),
})
}
#[expect(
clippy::too_many_lines,
reason = "CLI dispatch handles all subcommands"
)]
fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
let cli = dispatch.cli;
let root = dispatch.root;
let output = dispatch.output;
let quiet = dispatch.quiet;
let threads = dispatch.threads;
match command {
Command::Check {
unused_files,
unused_exports,
unused_deps,
unused_types,
private_type_leaks,
unused_enum_members,
unused_class_members,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
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,
unresolved_imports,
unlisted_deps,
duplicate_exports,
circular_deps,
re_export_cycles,
boundary_violations,
stale_suppressions,
unused_catalog_entries,
empty_catalog_groups,
unresolved_catalog_references,
unused_dependency_overrides,
misconfigured_dependency_overrides,
},
trace_opts: TraceOptions {
trace_export: trace,
trace_file,
trace_dependency,
performance: cli.performance,
},
include_dupes,
top,
file,
},
),
Command::Watch { no_clear } => {
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,
};
watch::run_watch(&watch::WatchOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
production,
clear_screen: !no_clear,
explain: cli.explain,
include_entry_exports: cli.include_entry_exports,
})
}
Command::Fix {
dry_run,
yes,
no_create_config,
} => {
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,
};
fix::run_fix(&fix::FixOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
dry_run,
yes,
production,
no_create_config,
})
}
Command::Init {
toml,
hooks,
branch,
} => init::run_init(&init::InitOptions {
root,
use_toml: toml,
hooks,
branch: branch.as_deref(),
}),
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::CiTemplate { subcommand } => match subcommand {
CiTemplateCli::Gitlab { vendor, force } => {
ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
vendor_dir: vendor,
force,
})
}
},
Command::Config { path } => config::run_config(root, cli.config.as_deref(), path, output),
Command::Workspaces => {
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,
};
list::run_list(&ListOptions {
root,
config_path: &cli.config,
output,
threads,
no_cache: cli.no_cache,
entry_points: false,
files: false,
plugins: false,
boundaries: false,
workspaces: true,
production,
})
}
Command::List {
entry_points,
files,
plugins,
boundaries,
workspaces,
} => {
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,
};
list::run_list(&ListOptions {
root,
config_path: &cli.config,
output,
threads,
no_cache: cli.no_cache,
entry_points,
files,
plugins,
boundaries,
workspaces,
production,
})
}
Command::Dupes {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
} => dispatch_dupes(
dispatch,
&DupesDispatchArgs {
mode,
min_tokens,
min_lines,
min_occurrences,
threshold,
skip_local,
cross_language,
ignore_imports,
top,
trace,
},
),
Command::Health {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
effort,
score,
min_score,
min_severity,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
runtime_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} => {
let coverage =
coverage.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
let ownership = ownership || ownership_emails.is_some();
let hotspots = hotspots || ownership;
dispatch_health(
dispatch,
HealthDispatchArgs {
max_cyclomatic,
max_cognitive,
max_crap,
top,
sort,
complexity,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails: ownership_emails.map(EmailModeArg::to_config),
targets,
effort,
score,
min_score,
min_severity,
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,
},
)
}
Command::Flags { top } => {
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,
})
}
Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
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,
} => {
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 audit_cfg = match load_config(
root,
&cli.config,
output,
cli.no_cache,
threads,
cli.production,
quiet,
) {
Ok(c) => c.audit,
Err(code) => return code,
};
let production = match resolve_production_modes(
cli,
root,
output,
production_dead_code,
production_health,
production_dupes,
) {
Ok(production) => production,
Err(code) => return code,
};
let resolved_dead_code_baseline = resolve_audit_baseline_path(
root,
dead_code_baseline.as_deref(),
audit_cfg.dead_code_baseline.as_deref(),
);
let resolved_health_baseline = resolve_audit_baseline_path(
root,
health_baseline.as_deref(),
audit_cfg.health_baseline.as_deref(),
);
let resolved_dupes_baseline = resolve_audit_baseline_path(
root,
dupes_baseline.as_deref(),
audit_cfg.dupes_baseline.as_deref(),
);
let coverage =
coverage.or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
audit::run_audit(&audit::AuditOptions {
root,
config_path: &cli.config,
output,
no_cache: cli.no_cache,
threads,
quiet,
changed_since: cli.changed_since.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(),
explain: cli.explain,
explain_skipped: cli.explain_skipped,
performance: cli.performance,
group_by: cli.group_by,
dead_code_baseline: resolved_dead_code_baseline.as_deref(),
health_baseline: resolved_health_baseline.as_deref(),
dupes_baseline: resolved_dupes_baseline.as_deref(),
max_crap,
coverage: coverage.as_deref(),
coverage_root: coverage_root.as_deref(),
gate: gate.map_or(audit_cfg.gate, Into::into),
include_entry_exports: cli.include_entry_exports,
runtime_coverage: runtime_coverage.as_deref(),
min_invocations_hot,
})
}
Command::Schema => unreachable!("handled above"),
Command::Migrate {
toml,
jsonc,
dry_run,
from,
} => migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref()),
Command::License { subcommand } => license::run(&map_license_subcommand(subcommand)),
Command::Coverage { subcommand } => coverage::run(
map_coverage_subcommand(&subcommand, cli.explain),
&coverage::RunContext {
root,
config_path: &cli.config,
output,
quiet,
no_cache: cli.no_cache,
threads,
explain: cli.explain,
},
),
Command::SetupHooks {
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
} => setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
root,
agent,
dry_run,
force,
user,
gitignore_claude,
uninstall,
}),
}
}
fn run_hooks_command(
root: &std::path::Path,
subcommand: HooksCli,
output: fallow_config::OutputFormat,
) -> ExitCode {
match subcommand {
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_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,
} => coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
yes: *yes,
non_interactive: *non_interactive || *json,
json: *json,
explain,
}),
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,
} => 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,
}),
CoverageCli::UploadInventory {
api_key,
api_endpoint,
project_id,
git_sha,
allow_dirty,
exclude_paths,
path_prefix,
dry_run,
ignore_upload_errors,
} => 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,
}),
CoverageCli::UploadSourceMaps {
dir,
include,
exclude,
repo,
git_sha,
endpoint,
strip_path,
dry_run,
concurrency,
fail_fast,
} => 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,
}),
}
}
struct CheckDispatchArgs {
filters: IssueFilters,
trace_opts: TraceOptions,
include_dupes: bool,
top: Option<usize>,
file: Vec<std::path::PathBuf>,
}
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(),
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,
})
}
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,
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: args.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(),
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 HealthDispatchArgs<'a> {
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
max_crap: Option<f64>,
top: Option<usize>,
sort: health::SortBy,
complexity: bool,
file_scores: bool,
coverage_gaps: bool,
hotspots: bool,
ownership: bool,
ownership_emails: Option<fallow_config::EmailMode>,
targets: bool,
effort: Option<EffortFilter>,
score: bool,
min_score: Option<f64>,
min_severity: Option<health_types::FindingSeverity>,
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>,
}
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,
file_scores,
coverage_gaps,
hotspots,
ownership,
ownership_emails,
targets,
effort,
score,
min_score,
min_severity,
since,
min_commits,
save_snapshot,
trend,
coverage,
coverage_root,
runtime_coverage,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
} = args;
let targets = targets || effort.is_some();
let badge_format = matches!(output, fallow_config::OutputFormat::Badge);
let score = score || min_score.is_some() || trend || badge_format;
let snapshot_requested = save_snapshot.is_some();
let any_section = complexity || file_scores || coverage_gaps || hotspots || targets || score;
let eff_score = if any_section { score } else { true } || snapshot_requested;
let force_full = snapshot_requested || eff_score;
let needs_hotspot_vitals = snapshot_requested || trend;
let score_only_output =
score && !complexity && !file_scores && !coverage_gaps && !hotspots && !targets && !trend;
let eff_file_scores = if any_section { file_scores } else { true } || force_full;
let eff_coverage_gaps = if any_section { coverage_gaps } else { false };
let eff_hotspots = if any_section { hotspots } else { true } || needs_hotspot_vitals;
let eff_complexity = if any_section { complexity } else { true };
let eff_targets = if any_section { targets } else { true };
let runtime_coverage = if let Some(path) = runtime_coverage {
match health::coverage::prepare_options(
path,
min_invocations_hot,
min_observation_volume,
low_traffic_threshold,
output,
) {
Ok(options) => Some(options),
Err(code) => return code,
}
} else {
None
};
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,
};
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(),
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: eff_complexity,
file_scores: eff_file_scores,
coverage_gaps: eff_coverage_gaps,
config_activates_coverage_gaps: !any_section,
hotspots: eff_hotspots,
ownership: ownership && eff_hotspots,
ownership_emails,
targets: eff_targets,
force_full,
score_only_output,
enforce_coverage_gap_gate: true,
effort: effort.map(EffortFilter::to_estimate),
score: eff_score,
min_score,
min_severity,
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_root,
performance: cli.performance,
runtime_coverage,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_has_no_flag_collisions() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[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",
" audit",
"Workflow:",
" watch",
" fix",
"Project inspection:",
" list",
" workspaces",
" explain",
"Setup and configuration:",
" init",
" migrate",
" config",
" config-schema",
" plugin-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();
}
}
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 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");
}
}