use std::process::ExitCode;
use clap::{CommandFactory, ValueEnum};
#[cfg(test)]
use fallow_output::issue_output_contracts;
use fallow_output::{TsAliasMeta, issue_output_contract_by_code};
use fallow_types::issue_meta::{ISSUE_KIND_META, issue_is_fixable, issue_meta_by_code};
use fallow_types::mcp_manifest::{MCP_RESOURCES, MCP_TOOLS, RUNTIME_COVERAGE_LICENSE_NOTE};
use fallow_types::suppress::IssueKind;
use crate::Cli;
use crate::cli_format::Format;
use crate::explain::{
CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, SECURITY_RULES, rule_docs_url,
rule_severity_key,
};
pub fn run_schema(json_style: crate::json_style::JsonStyle) -> ExitCode {
let cmd = Cli::command();
let schema = build_cli_schema(&cmd);
match json_style.serialize(&schema) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: failed to serialize schema: {e}");
ExitCode::from(2)
}
}
}
pub fn build_cli_schema(cmd: &clap::Command) -> serde_json::Value {
let mut global_flags = Vec::new();
for arg in cmd.get_arguments() {
if arg.get_id() == "help" || arg.get_id() == "version" {
continue;
}
global_flags.push(build_arg_schema(arg));
}
let mut commands = Vec::new();
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let mut flags = Vec::new();
for arg in sub.get_arguments() {
if arg.get_id() == "help" || arg.get_id() == "version" {
continue;
}
flags.push(build_arg_schema(arg));
}
commands.push(serde_json::json!({
"name": sub.get_name(),
"description": sub.get_about().map(std::string::ToString::to_string),
"flags": flags,
}));
}
serde_json::json!({
"name": cmd.get_name(),
"version": env!("CARGO_PKG_VERSION"),
"manifest_version": "1",
"description": cmd.get_about().map(std::string::ToString::to_string),
"global_flags": global_flags,
"commands": commands,
"default_command": null,
"default_behavior": "Runs all analyses (check + dupes + health). Use --only/--skip to select.",
"issue_types": issue_types_schema(),
"suppression_comments": {
"next_line": "// fallow-ignore-next-line [issue-type]",
"file": "// fallow-ignore-file [issue-type]",
"note": "Omit [issue-type] to suppress all issue types. Unknown tokens are silently ignored."
},
"output_formats": output_formats_schema(),
"exit_codes": exit_codes_schema(),
"environment_variables": environment_variables_schema(),
"severity_levels": ["error", "warn", "off"],
"field_notes": {
"default_severity": "The rule's severity under zero config (error/warn/off). null means the finding is not gated by a rules.* severity (a metric or command-driven finding, or a security-catalogue row).",
"opt_in": "true when the rule defaults to off and reports nothing until enabled. Enabling an opt_in rule blindly is the most common way to flood a repo with findings, so treat true as a deliberate decision.",
"frameworks": "Non-empty ONLY when the rule's DETECTOR self-gates on that framework (it does nothing unless the framework is declared). An EMPTY array does NOT mean the rule is framework-agnostic: many rules are framework-relevant (see the description) yet fire on any matching syntax, so never disable a rule solely because frameworks is empty.",
"config_key": "The canonical key under `rules` in the config file, i.e. rules.<config_key>. The `id` is an accepted alias. null for findings that are not configured via a rules.* severity."
},
"related_schemas": {
"note": "This manifest lists RULES, capabilities, presets, and the taste catalog. To author a config FILE you also need its shape, which is a separate schema.",
"config_schema_command": "fallow config-schema",
"config_schema_note": "Full JSON Schema of the config file: every top-level key (rules, entry, ignorePatterns, ignoreFindings, workspaces, boundaries, duplicates, health, security, rulePacks, production, cache, ...) and its shape. entry declares entry points; ignorePatterns excludes files from analysis; ignoreFindings hides source-owned dead-code findings after analysis while keeping files in the module graph. fallow also auto-honors package.json exports/main/module for library public APIs.",
"rule_pack_schema_command": "fallow rule-pack-schema",
"rule_pack_schema_note": "JSON Schema for a declarative rule pack referenced from rulePacks.",
"plugin_schema_command": "fallow plugin-schema",
"plugin_schema_note": "JSON Schema for a user-authored external plugin (fallow-plugin-*.jsonc). Teach fallow about an unsupported framework declaratively: detection, entryPoints, alwaysUsed, usedExports, usedClassMembers, and manifestEntries (derive entry points from per-package manifest files, including typed [*] traversal and exists predicates).",
"plugin_check_command": "fallow plugin-check",
"plugin_check_note": "Read-only dry-run of your external plugins: reports which activated, which manifests each manifestEntries rule matched, what it seeded (with path-exists), and typed warnings (manifests-matched-none, when-excluded-all, field-path-unresolved, entries-empty, manifest-parse-failed, field-values-limit-exceeded, entry-expansion-limit-exceeded, entry-outside-root, seeded-paths-missing). Run it after authoring a fallow-plugin-*.jsonc to verify it before a full analysis.",
"config_files": [".fallowrc.json", ".fallowrc.jsonc", "fallow.toml", ".fallow.toml"]
},
"boundary_presets": crate::onboarding::boundary_presets_schema(),
"taste_choices": crate::onboarding::taste_choices_schema(),
"security_categories": security_categories_schema(),
"mcp_tools": mcp_tools_schema(),
"mcp_resources": mcp_resources_schema(),
"plugins": plugins_schema(),
"task_matrix": task_matrix_schema(),
})
}
fn output_formats_schema() -> Vec<String> {
let mut formats = Vec::new();
for format in Format::value_variants() {
let Some(possible_value) = format.to_possible_value() else {
continue;
};
formats.extend(possible_value.get_name_and_aliases().map(str::to_owned));
}
formats
}
fn exit_codes_schema() -> serde_json::Value {
let entries = [
(0, "Success (no error-severity issues found)"),
(
1,
"Error-severity issues found (per rules config, or --fail-on-issues promotes warn to error)",
),
(
2,
"Error (invalid config, invalid input, etc.). When --format json is active, errors are emitted as structured JSON on stdout: {\"error\": true, \"message\": \"...\", \"exit_code\": 2}",
),
(
crate::exit_codes::RESOURCE_UNAVAILABLE_EXIT_CODE,
"Requested resource unavailable: config --path found no config, or a license is missing, invalid, or beyond its offline hard-fail window",
),
(
crate::exit_codes::RUNTIME_COVERAGE_SIDECAR_EXIT_CODE,
"Runtime coverage sidecar is unavailable, unverifiable, protocol-incompatible, or terminated unexpectedly",
),
(
crate::exit_codes::RUNTIME_COVERAGE_INPUT_EXIT_CODE,
"Runtime coverage input could not be prepared or parsed",
),
(
crate::exit_codes::RUNTIME_COVERAGE_INTERNAL_EXIT_CODE,
"Runtime coverage sidecar reported an internal error",
),
(
crate::exit_codes::NETWORK_EXIT_CODE,
"Network or cloud request failed during a license, coverage, or CI operation",
),
(
crate::exit_codes::SECURITY_GATE_EXIT_CODE,
"Security gate matched a candidate selected by --gate",
),
(
crate::exit_codes::COVERAGE_UPLOAD_VALIDATION_EXIT_CODE,
"Coverage inventory or static-findings upload input or project validation failed",
),
(
crate::exit_codes::COVERAGE_UPLOAD_PAYLOAD_TOO_LARGE_EXIT_CODE,
"Coverage inventory or static-findings upload exceeded the server payload limit",
),
(
crate::exit_codes::COVERAGE_UPLOAD_AUTH_REJECTED_EXIT_CODE,
"Coverage inventory or static-findings upload authentication or authorization was rejected",
),
(
crate::exit_codes::COVERAGE_UPLOAD_SERVER_ERROR_EXIT_CODE,
"Coverage inventory or static-findings upload failed after retries or returned another server error",
),
];
serde_json::Value::Object(
entries
.into_iter()
.map(|(code, description)| (code.to_string(), description.into()))
.collect(),
)
}
fn task_matrix_schema() -> serde_json::Value {
serde_json::Value::Array(
crate::task_matrix::TASK_MATRIX
.iter()
.map(fallow_types::task_matrix::TaskRow::to_json)
.collect(),
)
}
#[derive(Default)]
struct IssueTypeMeta {
label: Option<&'static str>,
config_key: Option<&'static str>,
registry_index: Option<usize>,
aliases: &'static [&'static str],
lsp: bool,
filter_flag: Option<&'static str>,
result_key: Option<&'static str>,
summary_label: Option<&'static str>,
summary_docs_anchor: Option<&'static str>,
sarif_rule_ids: Option<Vec<String>>,
codeclimate_check_names: Option<Vec<String>>,
ts_alias: Option<TsAliasMeta>,
counts_in_total: bool,
fixable: bool,
suppress: Option<(&'static str, bool)>,
note: Option<&'static str>,
freemium: bool,
}
impl IssueTypeMeta {
fn from_shared(bare_id: &str) -> Self {
let mut meta = Self {
fixable: issue_is_fixable(bare_id),
..Self::default()
};
if let Some(shared) = issue_meta_by_code(bare_id) {
meta.label = Some(shared.label);
meta.config_key = shared.config_key;
meta.registry_index = ISSUE_KIND_META
.iter()
.position(|candidate| candidate.code == shared.code);
meta.aliases = shared.aliases;
meta.lsp = shared.lsp;
meta.filter_flag = shared.filter_flag;
if let Some(token) = shared.suppress_token {
meta.suppress = Some((token, shared.suppress_file_level));
}
}
if let Some(contract) = issue_output_contract_by_code(bare_id) {
meta.result_key = Some(contract.result_key);
meta.summary_label = Some(contract.summary_label);
meta.summary_docs_anchor = Some(contract.summary_docs_anchor);
meta.sarif_rule_ids = Some(contract.sarif_rule_ids);
if !contract.codeclimate_check_names.is_empty() {
meta.codeclimate_check_names = Some(contract.codeclimate_check_names);
}
meta.ts_alias = contract.ts_alias;
meta.counts_in_total = contract.counts_in_total;
}
meta
}
}
fn security_categories_schema() -> serde_json::Value {
let categories: Vec<serde_json::Value> = fallow_security::security_categories()
.into_iter()
.map(|category| {
serde_json::json!({
"id": category.id,
"title": category.title,
"cwe": category.cwe,
"include_required": category.include_required,
})
})
.collect();
serde_json::json!({
"note": "Valid ids for security.categories.include / exclude. include-required categories (hardcoded-secret, secret-to-network) run only when explicitly listed in categories.include; all others are admitted by default unless an include list restricts to a whitelist.",
"categories": categories,
})
}
fn issue_types_schema() -> serde_json::Value {
let default_severities = fallow_api::schemas::default_rule_severities();
let mut rows = Vec::new();
for rule in CHECK_RULES {
rows.push(issue_type_row(rule, "dead-code", &default_severities));
}
for rule in HEALTH_RULES {
rows.push(issue_type_row(rule, "health", &default_severities));
}
for rule in DUPES_RULES {
rows.push(issue_type_row(rule, "dupes", &default_severities));
}
for rule in FLAGS_RULES {
rows.push(issue_type_row(rule, "flags", &default_severities));
}
for rule in SECURITY_RULES {
rows.push(issue_type_row(rule, "security", &default_severities));
}
serde_json::Value::Array(rows)
}
fn frameworks_for_kind(kind: IssueKind) -> &'static [&'static str] {
match kind {
IssueKind::RouteCollision
| IssueKind::DynamicSegmentNameConflict
| IssueKind::InvalidClientExport => &["next"],
_ => &[],
}
}
fn issue_type_row(
rule: &RuleDef,
command: &str,
default_severities: &serde_json::Value,
) -> serde_json::Value {
let bare_id = rule.id.split_once('/').map_or(rule.id, |(_, bare)| bare);
let meta = issue_type_meta(bare_id, command);
let kind = meta
.registry_index
.and_then(|i| ISSUE_KIND_META.get(i))
.and_then(|m| m.kind);
let severity_key = rule_severity_key(rule);
let default_severity = severity_key
.and_then(|key| default_severities.get(key))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let opt_in = default_severity.as_deref().map(|sev| sev == "off");
let frameworks = kind.map_or(&[][..], frameworks_for_kind);
let suppress_comment = meta.suppress.map(|(token, file_level)| {
if file_level {
format!("// fallow-ignore-file {token}")
} else {
format!("// fallow-ignore-next-line {token}")
}
});
serde_json::json!({
"id": bare_id,
"rule_id": rule.id,
"command": command,
"category": rule.category,
"description": rule.short,
"label": meta.label,
"config_key": meta.config_key,
"registry_index": meta.registry_index,
"aliases": meta.aliases,
"lsp": meta.lsp,
"filter_flag": meta.filter_flag,
"result_key": meta.result_key,
"summary_label": meta.summary_label,
"summary_docs_anchor": meta.summary_docs_anchor,
"sarif_rule_ids": meta.sarif_rule_ids,
"codeclimate_check_names": meta.codeclimate_check_names,
"ts_alias": meta.ts_alias.map(|alias| serde_json::json!({
"name": alias.name,
"parent": alias.parent,
})),
"counts_in_total": meta.counts_in_total,
"fixable": meta.fixable,
"suppressible": meta.suppress.is_some(),
"suppress_comment": suppress_comment,
"default_severity": default_severity,
"opt_in": opt_in,
"frameworks": frameworks,
"note": meta.note,
"license": if meta.freemium { "freemium" } else { "free" },
"license_note": meta.freemium.then_some(RUNTIME_COVERAGE_LICENSE_NOTE),
"docs_url": rule_docs_url(rule),
})
}
fn issue_type_meta(bare_id: &str, command: &str) -> IssueTypeMeta {
let mut meta = IssueTypeMeta::from_shared(bare_id);
match command {
"dead-code" => apply_dead_code_issue_meta(bare_id, &mut meta),
"health" => apply_health_issue_meta(bare_id, &mut meta),
"security" => apply_security_issue_meta(bare_id, &mut meta),
_ => apply_standalone_issue_meta(bare_id, &mut meta),
}
meta
}
fn apply_dead_code_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) {
apply_source_issue_meta(bare_id, m);
apply_dependency_issue_meta(bare_id, m);
apply_architecture_issue_meta(bare_id, m);
}
fn apply_source_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) -> bool {
match bare_id {
"private-type-leak" => {
m.note = Some("Opt-in API hygiene check; the rule defaults to off");
}
"unused-export"
| "unused-enum-member"
| "unused-file"
| "unused-type"
| "unresolved-import"
| "missing-suppression-reason"
| "unused-class-member"
| "unused-store-member"
| "unprovided-inject"
| "unrendered-component"
| "unused-component-prop"
| "unused-component-emit"
| "unused-component-input"
| "unused-component-output"
| "unused-svelte-event"
| "unused-server-action"
| "unused-load-data-key" => {}
"prop-drilling" => {
m.note = Some(
"Opt-in: set rules.prop-drilling to warn or error to enable. Defaults to off.",
);
}
"thin-wrapper" => {
m.note =
Some("Opt-in: set rules.thin-wrapper to warn or error to enable. Defaults to off.");
}
"duplicate-prop-shape" => {
m.note = Some(
"Opt-in: set rules.duplicate-prop-shape to warn or error to enable. Defaults to off.",
);
}
"duplicate-export" => {
m.note = Some(
"fallow fix can add an ignoreExports rule to the fallow config instead of editing source",
);
}
"stale-suppression" => {
m.note = Some("Fix by removing the stale suppression marker itself");
}
_ => return false,
}
true
}
fn apply_dependency_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) -> bool {
match bare_id {
"unused-dependency" | "unused-dev-dependency" | "unused-optional-dependency" => {
m.note = Some(
"--unused-deps controls unused-dependency, unused-dev-dependency, unused-optional-dependency, type-only-dependency, and test-only-dependency",
);
}
"type-only-dependency" => {
m.note = Some(
"Only reported in --production mode; --unused-deps scopes it together with the other dependency kinds",
);
}
"test-only-dependency" => {
m.note = Some(
"Not reported in --production mode (test files are excluded there); --unused-deps scopes it together with the other dependency kinds",
);
}
"unlisted-dependency" => {}
_ => return false,
}
true
}
fn apply_architecture_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) -> bool {
match bare_id {
"circular-dependency" | "re-export-cycle" => {}
"boundary-violation" => {
m.note = Some("Requires configured boundary zones (boundaries config)");
}
"boundary-coverage" => {
m.note = Some("Requires boundaries.coverage.requireAllFiles");
}
"boundary-call-violation" => {
m.note = Some("Requires boundaries.calls.forbidden patterns");
}
"policy-violation" => {
m.note = Some("Requires a configured rule pack (rulePacks config)");
}
"invalid-client-export" | "mixed-client-server-barrel" | "misplaced-directive" => {
m.note = Some("Requires the project to declare next");
}
_ => return false,
}
true
}
fn apply_health_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) {
match bare_id {
"high-cyclomatic-complexity"
| "high-cognitive-complexity"
| "high-complexity"
| "high-crap-score" => {
m.filter_flag = Some("--complexity");
m.suppress = Some(("complexity", false));
}
"refactoring-target" => {
m.filter_flag = Some("--targets");
}
"untested-file" | "untested-export" => {
m.filter_flag = Some("--coverage-gaps");
m.suppress = Some(("coverage-gaps", true));
}
"runtime-safe-to-delete"
| "runtime-review-required"
| "runtime-low-traffic"
| "runtime-coverage-unavailable"
| "runtime-coverage" => {
m.freemium = true;
m.note =
Some("Requires --runtime-coverage input (V8 directory, V8 JSON, or Istanbul map)");
}
"coverage-intelligence-risky-change"
| "coverage-intelligence-delete"
| "coverage-intelligence-review"
| "coverage-intelligence-refactor" => {
m.freemium = true;
m.note = Some("Produced by fallow coverage analyze");
}
_ => {}
}
}
fn apply_standalone_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) {
match bare_id {
"code-duplication" => {
m.suppress = Some(("code-duplication", false));
m.note = Some("Reported by fallow dupes (and bare fallow / fallow audit)");
}
"feature-flag" => {
m.suppress = Some(("feature-flag", false));
m.note = Some("Reported by fallow flags");
}
_ => {}
}
}
fn apply_security_issue_meta(bare_id: &str, m: &mut IssueTypeMeta) {
match bare_id {
"client-server-leak" => {
m.suppress = Some(("security-client-server-leak", true));
}
"hardcoded-secret" => {
m.suppress = Some(("security-sink", false));
m.note = Some("Include-required category: enable via security.categories.include");
}
"tainted-sink" => {
m.suppress = Some(("security-sink", false));
}
_ => {
m.suppress = Some(("security-sink", false));
m.note = Some(
"Tainted-sink catalogue category; the security-sink suppression token covers every category",
);
}
}
}
fn mcp_tools_schema() -> serde_json::Value {
let tools: Vec<serde_json::Value> = MCP_TOOLS
.iter()
.map(fallow_types::mcp_manifest::McpToolInfo::to_json)
.collect();
serde_json::json!({
"server": "fallow-mcp",
"note": fallow_types::mcp_manifest::MCP_TOOLS_KEY_PARAMS_NOTE,
"tools": tools,
})
}
fn mcp_resources_schema() -> serde_json::Value {
let resources: Vec<serde_json::Value> = MCP_RESOURCES
.iter()
.map(fallow_types::mcp_manifest::McpResourceInfo::to_json)
.collect();
serde_json::json!({
"server": "fallow-mcp",
"note": "Read-only reference material served in-process by fallow-mcp; every JSON payload carries fallow_version. Rows with template: true are RFC 6570 URI templates listed under resources/templates/list; the catalogue is static, so the server declares neither subscribe nor listChanged",
"resources": resources,
})
}
fn plugins_schema() -> serde_json::Value {
let names = fallow_engine::plugins::registry::builtin_plugin_names();
serde_json::json!({
"count": names.len(),
"note": "Built-in framework plugins, auto-activated when their enabler dependency is present; run fallow list --plugins for the set active in a specific project",
"names": names,
})
}
const ENVIRONMENT_VARIABLES: &[(&str, &str)] = &[
(
"FALLOW_FORMAT",
"Default output format (json/human/sarif/compact/markdown/codeclimate/gitlab-codequality/pr-comment-github/pr-comment-gitlab/review-github/review-gitlab/badge/github-annotations/github-summary). CLI --format flag overrides this.",
),
(
"FALLOW_QUIET",
"Set to \"1\" or \"true\" to suppress progress output. CLI --quiet flag overrides this.",
),
(
"FALLOW_PRODUCTION",
"Set to true/false to override production mode for all analyses.",
),
(
"FALLOW_PRODUCTION_DEAD_CODE",
"Set to true/false to override production mode for dead-code analysis.",
),
(
"FALLOW_PRODUCTION_HEALTH",
"Set to true/false to override production mode for health analysis.",
),
(
"FALLOW_PRODUCTION_DUPES",
"Set to true/false to override production mode for duplication analysis.",
),
(
"FALLOW_REVIEW_GUIDANCE",
"Set to true to append collapsed guidance blocks to review-github/review-gitlab inline comment bodies.",
),
(
"FALLOW_REVIEW_ID",
"Stable 1-64 character identifier that isolates inline comments when multiple review jobs target the same PR/MR.",
),
(
"FALLOW_SUMMARY_SCOPE",
"Summary scope for pr-comment-github/pr-comment-gitlab: all (default) keeps project-level dependency/catalog/override findings outside the diff filter; diff applies the diff filter to them too. Inline review comments are unaffected.",
),
(
"FALLOW_PR_COMMENT_LAYOUT",
"Sticky PR comment layout: default, compact, gate-only, or details.",
),
(
"FALLOW_CONSOLIDATED_STATUS",
"When split PR gate check runs are enabled, truthy values add one aggregate Fallow check alongside the per-gate checks.",
),
(
"FALLOW_DIFF_CONTEXT",
"Line radius around changed diff lines when scoping findings to a diff in the review/PR-comment formats (default 3).",
),
(
"FALLOW_BOT_LOGIN",
"Allowlisted posting username used to recognize fallow-owned finding roots and resolution replies. When set, it narrows ownership to that exact username (an empty value matches nobody); when unset, provider-native bot metadata is trusted. This is an authorship control, not a lifecycle or deduplication token.",
),
(
"FALLOW_API_RETRIES",
"Maximum HTTP attempts for review-comment reconciliation API calls (default 3).",
),
(
"FALLOW_API_RETRY_DELAY",
"Floor delay in seconds between HTTP retry attempts (default 2); a server-supplied Retry-After overrides it on 429 responses.",
),
(
"FALLOW_CACHE_DIR",
"Directory for fallow's persistent analysis cache. Relative paths resolve from the project root and override cache.dir.",
),
(
"FALLOW_CACHE_MAX_SIZE",
"Extraction cache size cap in megabytes (default 256). Wins over the cache.maxSizeMb config field.",
),
(
"FALLOW_EXTENDS_TIMEOUT_SECS",
"Timeout in seconds for fetching https:// configs referenced via the extends field (default 5).",
),
(
"FALLOW_COVERAGE",
"Path to Istanbul coverage data (coverage-final.json) for accurate per-function CRAP scores. CLI --coverage flag overrides this; it wins over the health.coverage config field. Honored by the health, bare fallow, audit, and viz CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage parameter overrides it. Viz reads it through the same precedence and has no coverage flag of its own.",
),
(
"FALLOW_COVERAGE_ROOT",
"Absolute coverage-data path prefix for rebasing Istanbul paths in CI or containers. CLI --coverage-root flag overrides this; it wins over the health.coverageRoot config field. Honored by the health, bare fallow, audit, and viz CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage_root parameter overrides it. Viz reads it through the same precedence and has no coverage flag of its own.",
),
(
"FALLOW_MAX_FILE_SIZE",
"Per-file size ceiling in megabytes for source discovery (default 5; 0 = no limit). CLI --max-file-size flag overrides this.",
),
(
"FALLOW_TYPE_AWARE",
"Enable or disable TypeScript semantic (type-aware) analysis for the run (true/false/1/0/yes/no/on/off). Precedence: --type-aware/--no-type-aware CLI flags, then FALLOW_TYPE_AWARE, then the audit.typeAware config field, then typeAware.enabled.",
),
(
"FALLOW_TYPE_AWARE_TIMEOUT_SECS",
"Wall clock in seconds allowed for one TypeScript semantic sidecar request (default 120; unset, 0, or a non-numeric value keeps the default). Raise it for very large TypeScript programs, where a semantic query scans the whole program.",
),
(
"FALLOW_TYPE_AWARE_BIN",
"Trusted executable override for the TypeScript semantic refinement sidecar used by dead-code --type-aware. Relative paths resolve from the caller's working directory before --root is applied; project node_modules and PATH are intentionally not searched. Default: sibling of the active Fallow executable.",
),
(
"FALLOW_AUDIT_BASE",
"Pins the fallow audit comparison base ref when no --base/--changed-since is passed (e.g. upstream/main).",
),
(
"FALLOW_AUDIT_CACHE_MAX_AGE_DAYS",
"GC threshold in days for reusable audit base-snapshot caches (default 30; 0 disables the sweep).",
),
(
"FALLOW_IMPACT_STORE_MAX_AGE_DAYS",
"GC threshold in days for per-project fallow impact stores; a recorded run reclaims stores older than this (unset/0 keeps every store forever).",
),
(
"FALLOW_ROOT",
"Project root used by the review-github/review-gitlab renderers to read source for suggestion blocks. Set it alongside --root when rendering review formats outside the bundled CI integrations.",
),
(
"FALLOW_LICENSE",
"License JWT (full string) for the paid runtime intelligence layer; intended for shared CI runners.",
),
(
"FALLOW_LICENSE_PATH",
"File path containing the license JWT.",
),
(
"FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS",
"Clock-skew tolerance applied to the license JWT's iat claim (default 86400).",
),
(
"FALLOW_COV_BIN",
"Explicit path override for the fallow-cov runtime-coverage sidecar binary.",
),
(
"FALLOW_COV_BINARY_PATH",
"Secondary explicit path override for the fallow-cov sidecar, checked after FALLOW_COV_BIN (air-gapped installs, distro-packaged sidecars, shared Docker images).",
),
(
"FALLOW_RUNTIME_COVERAGE_SOURCE",
"Set to cloud to select cloud runtime coverage in fallow coverage analyze without passing --cloud.",
),
(
"FALLOW_REPO",
"owner/repo fallback for fallow coverage analyze --cloud when --repo is not passed (otherwise parsed from the git origin remote).",
),
(
"FALLOW_API_URL",
"Base URL override for fallow cloud API calls (license refresh, trial, coverage uploads).",
),
(
"FALLOW_API_KEY",
"fallow cloud bearer token for coverage upload commands, and the fallback bearer for fallow license refresh when the stored license JWT is missing or too stale.",
),
(
"FALLOW_CA_BUNDLE",
"Path to a PEM certificate bundle for fallow cloud and provider HTTP calls (replaces the default WebPKI roots).",
),
(
"FALLOW_UPDATE_CHECK",
"Set to off/0/false to disable the human-TTY upgrade nudge and its background version check.",
),
(
"FALLOW_SUGGESTIONS",
"Set to off/0/false/no/disabled to suppress the next_steps[] array of read-only follow-up commands in JSON output (and the human Next: line). Useful for CI consumers that snapshot-diff raw --format json output. Default on.",
),
(
"FALLOW_TELEMETRY",
"Opt-in telemetry mode: off, on, or inspect (print the payload to stderr without sending). Telemetry is off by default.",
),
(
"FALLOW_TELEMETRY_DISABLED",
"Admin/fleet kill switch: truthy values hard-disable telemetry and refuse fallow telemetry enable.",
),
(
"FALLOW_TELEMETRY_DEBUG",
"Truthy values alias FALLOW_TELEMETRY=inspect.",
),
(
"FALLOW_AGENT_SOURCE",
"Normalized agent vendor for telemetry classification (e.g. claude_code, codex, cursor). Only read when telemetry is on.",
),
(
"DO_NOT_TRACK",
"Honored as a top-precedence telemetry kill switch (consoledonottrack.com convention).",
),
(
"FALLOW_BIN",
"Path to the fallow binary (used by the fallow-mcp server to spawn the CLI).",
),
(
"FALLOW_TIMEOUT_SECS",
"MCP server: per-tool-call CLI subprocess timeout in seconds (default 120). Raise for long runs like production coverage on large dumps.",
),
(
"FALLOW_DIFF_FILE",
"MCP server: path to a unified diff that scopes all findings by changed line.",
),
(
"FALLOW_CHANGED_SINCE",
"MCP server: git ref that scopes file discovery for analysis tools.",
),
(
"FALLOW_INTEGRATION_SURFACE",
"Telemetry integration_surface override for non-CLI surfaces (mcp/lsp/vscode/napi/programmatic). Set by the MCP server on the CLI it spawns.",
),
(
"FALLOW_MCP_TOOL",
"Telemetry mcp_tool dimension, validated against the MCP tool-name allowlist. Set by the MCP server alongside FALLOW_INTEGRATION_SURFACE=mcp.",
),
];
fn environment_variables_schema() -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> = ENVIRONMENT_VARIABLES
.iter()
.map(|(name, description)| ((*name).to_string(), serde_json::Value::from(*description)))
.collect();
serde_json::Value::Object(map)
}
fn build_arg_schema(arg: &clap::Arg) -> serde_json::Value {
let name = arg
.get_long()
.map_or_else(|| arg.get_id().to_string(), |l| format!("--{l}"));
let arg_type = match arg.get_action() {
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => "bool",
clap::ArgAction::Count => "count",
_ => "string",
};
let possible: Vec<String> = arg
.get_possible_values()
.iter()
.map(|v| v.get_name().to_string())
.collect();
let mut schema = serde_json::json!({
"name": name,
"type": arg_type,
"required": arg.is_required_set(),
"description": arg.get_help().map(std::string::ToString::to_string),
});
if let Some(short) = arg.get_short() {
schema["short"] = serde_json::json!(format!("-{short}"));
}
if let Some(default) = arg.get_default_values().first() {
schema["default"] = serde_json::json!(default.to_str());
}
if !possible.is_empty() {
schema["possible_values"] = serde_json::json!(possible);
}
schema
}
#[cfg(test)]
mod tests {
use fallow_types::results::TOTAL_ISSUE_RESULT_KEYS;
use fallow_types::suppress::{DEAD_CODE_FILTER_FLAGS, IssueKind, KNOWN_ISSUE_KIND_NAMES};
use rustc_hash::FxHashSet;
use super::*;
fn schema() -> serde_json::Value {
let cmd = Cli::command();
build_cli_schema(&cmd)
}
fn subcommand_flags(name: &str) -> FxHashSet<String> {
let cmd = Cli::command();
let sub = cmd
.get_subcommands()
.find(|s| s.get_name() == name)
.unwrap_or_else(|| panic!("no subcommand named {name}"));
sub.get_arguments()
.filter_map(|a| a.get_long().map(|l| format!("--{l}")))
.collect()
}
#[test]
fn related_schemas_points_at_plugin_authoring() {
let schema = schema();
let related = &schema["related_schemas"];
assert_eq!(related["plugin_schema_command"], "fallow plugin-schema");
assert_eq!(related["plugin_check_command"], "fallow plugin-check");
assert!(
related["plugin_schema_note"].is_string() && related["plugin_check_note"].is_string(),
"plugin schema/check pointers must carry an agent-facing note"
);
}
#[test]
fn schema_includes_environment_variables() {
let schema = schema();
let env_vars = &schema["environment_variables"];
assert!(env_vars["FALLOW_FORMAT"].is_string());
assert!(env_vars["FALLOW_QUIET"].is_string());
assert!(env_vars["FALLOW_CACHE_DIR"].is_string());
assert!(env_vars["FALLOW_BIN"].is_string());
assert!(env_vars["FALLOW_CACHE_MAX_SIZE"].is_string());
assert!(env_vars["FALLOW_TELEMETRY"].is_string());
assert!(env_vars["FALLOW_AUDIT_BASE"].is_string());
assert!(env_vars["FALLOW_IMPACT_STORE_MAX_AGE_DAYS"].is_string());
assert!(env_vars["FALLOW_TIMEOUT_SECS"].is_string());
assert!(env_vars["FALLOW_SUGGESTIONS"].is_string());
assert!(env_vars["DO_NOT_TRACK"].is_string());
}
#[test]
fn coverage_environment_variables_name_every_honoring_surface() {
let schema = schema();
let env_vars = env_var_map(&schema);
for (var, config_field, parameter) in [
(
"FALLOW_COVERAGE",
"health.coverage config field",
"coverage",
),
(
"FALLOW_COVERAGE_ROOT",
"health.coverageRoot config field",
"coverage_root",
),
] {
let description = env_vars[var].as_str().unwrap();
for required in [
config_field,
"health, bare fallow, audit, and viz CLI commands",
"MCP audit and check_health tools",
"typed route",
"CLI fallback",
&format!("explicit {parameter} parameter overrides it"),
] {
assert!(
description.contains(required),
"{var} description must mention {required:?}: {description}"
);
}
assert!(
!description.contains("only when they fall back"),
"{var} must not describe the typed route as config-blind: {description}"
);
}
}
#[test]
fn environment_variables_exclude_internal_plumbing() {
const EXCLUDED: &[(&str, &str)] = &[
("FALLOW_TEST_SIGNAL_HELPER", "test harness only"),
("FALLOW_STUB_MODE", "test harness only"),
(
"FALLOW_RAYON_STACK_PROBE_CHILD",
"internal child-process marker",
),
(
"FALLOW_PROGRAMMATIC_SHARED_DIFF_CHILD",
"internal child-process marker",
),
(
"FALLOW_GITLAB_BASE_SHA",
"set by the bundled GitLab CI template, not user-configured",
),
(
"FALLOW_GITLAB_START_SHA",
"set by the bundled GitLab CI template, not user-configured",
),
(
"FALLOW_GITLAB_HEAD_SHA",
"set by the bundled GitLab CI template, not user-configured",
),
(
"FALLOW_COMMENT_ID",
"set by the bundled Action/CI scripts, not user-configured",
),
(
"FALLOW_MAX_COMMENTS",
"set by the bundled Action/CI scripts, not user-configured",
),
(
"FALLOW_DIFF_FILTER",
"set by the bundled Action/CI scripts, not user-configured",
),
(
"FALLOW_TYPE_AWARE_BIN_SOURCE",
"set by the npm launcher and Node-API loader, not user-configured",
),
];
let schema = schema();
let env_vars = env_var_map(&schema);
for (var, reason) in EXCLUDED {
assert!(
!env_vars.contains_key(*var),
"{var} is internal plumbing ({reason}) and must not be documented in the manifest"
);
}
}
fn env_var_map(schema: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
schema["environment_variables"].as_object().unwrap().clone()
}
#[test]
fn schema_exit_code_2_mentions_json_errors() {
let schema = schema();
let exit_2 = schema["exit_codes"]["2"].as_str().unwrap();
assert!(exit_2.contains("JSON"));
}
#[test]
fn schema_documents_public_special_exit_codes() {
let schema = schema();
let exit_codes = schema["exit_codes"].as_object().unwrap();
let mut actual = exit_codes.keys().map(String::as_str).collect::<Vec<_>>();
actual.sort_unstable();
assert_eq!(
actual,
[
"0", "1", "10", "11", "12", "13", "2", "3", "4", "5", "6", "7", "8"
]
);
}
#[test]
fn schema_has_name_and_version() {
let schema = schema();
assert_eq!(schema["name"], "fallow");
assert!(schema["version"].is_string());
assert_eq!(schema["manifest_version"], "1");
}
#[test]
fn schema_has_commands_array() {
let schema = schema();
let commands = schema["commands"].as_array().unwrap();
assert!(!commands.is_empty());
assert!(
!commands
.iter()
.any(|c| c["name"].as_str().unwrap() == "help")
);
}
#[test]
fn schema_has_global_flags() {
let schema = schema();
let flags = schema["global_flags"].as_array().unwrap();
assert!(!flags.iter().any(|f| f["name"].as_str().unwrap() == "help"));
assert!(
!flags
.iter()
.any(|f| f["name"].as_str().unwrap() == "version")
);
}
#[test]
fn schema_has_issue_types() {
let schema = schema();
let issue_types = schema["issue_types"].as_array().unwrap();
assert!(!issue_types.is_empty());
for issue_type in issue_types {
assert!(issue_type["id"].is_string());
assert!(issue_type["description"].is_string());
}
}
#[test]
fn issue_types_cover_every_explain_rule() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let expected = CHECK_RULES.len()
+ HEALTH_RULES.len()
+ DUPES_RULES.len()
+ FLAGS_RULES.len()
+ SECURITY_RULES.len();
assert_eq!(rows.len(), expected, "one issue_types row per explain rule");
let row_rule_ids: FxHashSet<&str> = rows
.iter()
.map(|r| r["rule_id"].as_str().unwrap())
.collect();
assert_eq!(
row_rule_ids.len(),
rows.len(),
"duplicate rule_id in issue_types"
);
for rule in CHECK_RULES
.iter()
.chain(HEALTH_RULES)
.chain(DUPES_RULES)
.chain(FLAGS_RULES)
.chain(SECURITY_RULES)
{
assert!(
row_rule_ids.contains(rule.id),
"explain rule {} has no issue_types row",
rule.id
);
}
}
#[test]
fn every_issue_kind_is_covered_by_a_row() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let mut covered: FxHashSet<u8> = FxHashSet::default();
for row in rows {
if let Some(kind) = IssueKind::parse(row["id"].as_str().unwrap()) {
covered.insert(kind.to_discriminant());
}
if let Some(comment) = row["suppress_comment"].as_str() {
let token = comment.split_whitespace().last().unwrap();
if let Some(kind) = IssueKind::parse(token) {
covered.insert(kind.to_discriminant());
}
}
}
for name in KNOWN_ISSUE_KIND_NAMES.iter() {
let kind = IssueKind::parse(name).unwrap();
assert!(
covered.contains(&kind.to_discriminant()),
"IssueKind for token '{name}' has no issue_types row (neither id nor suppress token)"
);
}
}
#[test]
fn suppress_comments_round_trip_through_issue_kind_parse() {
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
let suppressible = row["suppressible"].as_bool().unwrap();
let comment = &row["suppress_comment"];
assert_eq!(
comment.is_string(),
suppressible,
"suppress_comment must be a string iff suppressible ({})",
row["id"]
);
if let Some(comment) = comment.as_str() {
assert!(
comment.starts_with("// fallow-ignore-next-line ")
|| comment.starts_with("// fallow-ignore-file "),
"unexpected suppress_comment shape: {comment}"
);
let token = comment.split_whitespace().last().unwrap();
assert!(
IssueKind::parse(token).is_some(),
"suppress_comment token '{token}' on row {} does not parse; agents would copy a no-op suppression",
row["id"]
);
}
}
}
#[test]
fn dead_code_schema_issue_contracts_follow_issue_kind_meta() {
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
if row["command"].as_str().unwrap() != "dead-code" {
continue;
}
let id = row["id"].as_str().unwrap();
let shared = issue_meta_by_code(id)
.unwrap_or_else(|| panic!("dead-code row {id} has no IssueKindMeta"));
assert_eq!(
row["filter_flag"].as_str(),
shared.filter_flag,
"dead-code row {id} must derive filter_flag from IssueKindMeta"
);
let expected_comment = shared.suppress_token.map(|token| {
if shared.suppress_file_level {
format!("// fallow-ignore-file {token}")
} else {
format!("// fallow-ignore-next-line {token}")
}
});
assert_eq!(
row["suppressible"].as_bool().unwrap(),
expected_comment.is_some(),
"dead-code row {id} must derive suppressible from IssueKindMeta"
);
assert_eq!(
row["suppress_comment"].as_str(),
expected_comment.as_deref(),
"dead-code row {id} must derive suppress_comment from IssueKindMeta"
);
}
}
#[test]
fn issue_type_registry_fields_follow_issue_kind_meta() {
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
let id = row["id"].as_str().unwrap();
let Some(shared) = issue_meta_by_code(id) else {
assert!(
row["label"].is_null(),
"unregistered row {id} must not invent a label"
);
assert!(
row["config_key"].is_null(),
"unregistered row {id} must not invent a config key"
);
assert!(
row["registry_index"].is_null(),
"unregistered row {id} must not invent a registry index"
);
assert_eq!(
row["aliases"].as_array().map(Vec::len),
Some(0),
"unregistered row {id} must not invent aliases"
);
assert_eq!(
row["lsp"].as_bool(),
Some(false),
"unregistered row {id} must not be exposed as an LSP issue type"
);
continue;
};
assert_eq!(
row["label"].as_str(),
Some(shared.label),
"row {id} must derive label from IssueKindMeta"
);
assert_eq!(
row["config_key"].as_str(),
shared.config_key,
"row {id} must derive config_key from IssueKindMeta"
);
assert!(
row["registry_index"].as_u64().is_some(),
"row {id} must derive registry_index from IssueKindMeta"
);
let aliases: Vec<&str> = row["aliases"]
.as_array()
.unwrap()
.iter()
.map(|alias| alias.as_str().unwrap())
.collect();
assert_eq!(
aliases, shared.aliases,
"row {id} must derive aliases from IssueKindMeta"
);
assert_eq!(
row["lsp"].as_bool(),
Some(shared.lsp),
"row {id} must derive lsp from IssueKindMeta"
);
}
}
#[test]
fn issue_type_default_severity_and_opt_in_track_the_gating_rule() {
let defaults = serde_json::to_value(fallow_config::RulesConfig::default()).unwrap();
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
let id = row["id"].as_str().unwrap();
let suppress_rule = row["suppress_comment"]
.as_str()
.and_then(|comment| comment.rsplit(' ').next());
let severity_key = row["config_key"].as_str().or(suppress_rule);
let expected = severity_key
.and_then(|key| defaults.get(key))
.and_then(serde_json::Value::as_str);
assert_eq!(
row["default_severity"].as_str(),
expected,
"row {id} default_severity must equal the loader default for its gating rule ({severity_key:?})"
);
match row["default_severity"].as_str() {
Some(sev) => {
assert!(
["error", "warn", "off"].contains(&sev),
"row {id} default_severity {sev} is not a severity level"
);
assert_eq!(
row["opt_in"].as_bool(),
Some(sev == "off"),
"row {id} opt_in must mean default severity off"
);
}
None => assert!(
row["opt_in"].is_null(),
"row {id} with no gating rule must carry null opt_in"
),
}
assert!(
row["frameworks"].is_array(),
"row {id} frameworks must always be an array"
);
}
}
#[test]
fn opt_in_command_findings_expose_opt_in() {
let schema = schema();
let opt_in = |id: &str| {
schema["issue_types"]
.as_array()
.unwrap()
.iter()
.find(|row| row["id"] == id)
.and_then(|row| row["opt_in"].as_bool())
};
for id in ["tainted-sink", "client-server-leak", "hardcoded-secret"] {
assert_eq!(
opt_in(id),
Some(true),
"security finding {id} must read as opt-in (off by default)"
);
}
}
#[test]
fn opt_in_rows_are_the_off_default_rules() {
let schema = schema();
let mut opt_in_ids: Vec<&str> = schema["issue_types"]
.as_array()
.unwrap()
.iter()
.filter(|row| row["opt_in"].as_bool() == Some(true))
.map(|row| row["id"].as_str().unwrap())
.collect();
opt_in_ids.sort_unstable();
opt_in_ids.dedup();
for expected in [
"private-type-leak",
"prop-drilling",
"thin-wrapper",
"duplicate-prop-shape",
"feature-flag",
] {
assert!(
opt_in_ids.contains(&expected),
"expected opt-in rule {expected} to be flagged opt_in in the manifest, got {opt_in_ids:?}"
);
}
}
#[test]
fn only_self_gating_rules_carry_frameworks() {
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
let id = row["id"].as_str().unwrap();
let frameworks: Vec<&str> = row["frameworks"]
.as_array()
.unwrap()
.iter()
.map(|f| f.as_str().unwrap())
.collect();
if frameworks.is_empty() {
continue;
}
assert!(
matches!(
id,
"route-collision" | "dynamic-segment-name-conflict" | "invalid-client-export"
),
"row {id} carries frameworks {frameworks:?} but its detector is not known to self-gate"
);
assert_eq!(frameworks, ["next"], "row {id} unexpected frameworks");
}
}
#[test]
fn schema_has_security_categories_with_include_required_flags() {
let schema = schema();
let cats = schema["security_categories"]["categories"]
.as_array()
.expect("security_categories.categories is an array");
assert!(!cats.is_empty(), "security categories must be listed");
for c in cats {
assert!(c["id"].as_str().is_some(), "each category has an id");
assert!(c["include_required"].as_bool().is_some());
}
let flagged: Vec<&str> = cats
.iter()
.filter(|c| c["include_required"].as_bool() == Some(true))
.map(|c| c["id"].as_str().unwrap())
.collect();
assert!(flagged.contains(&"hardcoded-secret"));
assert!(flagged.contains(&"secret-to-network"));
}
#[test]
fn issue_type_nullable_fields_are_always_present() {
let schema = schema();
for row in schema["issue_types"].as_array().unwrap() {
let obj = row.as_object().unwrap();
for key in [
"filter_flag",
"result_key",
"summary_label",
"summary_docs_anchor",
"sarif_rule_ids",
"codeclimate_check_names",
"ts_alias",
"suppress_comment",
"default_severity",
"opt_in",
"frameworks",
"note",
"license_note",
"rule_id",
"command",
"category",
"label",
"config_key",
"registry_index",
"aliases",
"lsp",
"counts_in_total",
"fixable",
"suppressible",
"license",
"docs_url",
] {
assert!(
obj.contains_key(key),
"row {} is missing key {key}",
row["id"]
);
}
}
}
#[test]
fn dead_code_result_keys_match_total_issue_contract() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let expected: FxHashSet<&str> = TOTAL_ISSUE_RESULT_KEYS.iter().copied().collect();
let mut counted = FxHashSet::default();
let mut advisory = FxHashSet::default();
for row in rows {
if row["command"].as_str() != Some("dead-code") {
assert!(
row["result_key"].is_null(),
"non dead-code row {} must not expose a dead-code result_key",
row["id"]
);
assert!(
row["summary_label"].is_null(),
"non dead-code row {} must not expose a dead-code summary_label",
row["id"]
);
assert!(
row["summary_docs_anchor"].is_null(),
"non dead-code row {} must not expose a dead-code summary_docs_anchor",
row["id"]
);
assert!(
row["sarif_rule_ids"].is_null(),
"non dead-code row {} must not expose dead-code sarif_rule_ids",
row["id"]
);
assert!(
row["codeclimate_check_names"].is_null(),
"non dead-code row {} must not expose dead-code codeclimate_check_names",
row["id"]
);
assert!(
row["ts_alias"].is_null(),
"non dead-code row {} must not expose a dead-code ts_alias",
row["id"]
);
assert_eq!(
row["counts_in_total"].as_bool(),
Some(false),
"non dead-code row {} must not count in total_issues",
row["id"]
);
continue;
}
let counts = row["counts_in_total"].as_bool().unwrap();
if let Some(result_key) = row["result_key"].as_str() {
assert!(
row["summary_label"]
.as_str()
.is_some_and(|label| !label.is_empty()),
"dead-code row {} has result_key {result_key} but no summary_label",
row["id"]
);
assert!(
row["summary_docs_anchor"]
.as_str()
.is_some_and(|anchor| !anchor.is_empty()),
"dead-code row {} has result_key {result_key} but no summary_docs_anchor",
row["id"]
);
let sarif_rule_ids: FxHashSet<&str> = row["sarif_rule_ids"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap())
.collect();
assert!(
sarif_rule_ids.contains(row["rule_id"].as_str().unwrap()),
"dead-code row {} must include the rule id in SARIF rule ids",
row["id"]
);
if row["id"].as_str() == Some("stale-suppression") {
assert!(
sarif_rule_ids.contains("fallow/missing-suppression-reason"),
"stale-suppression must expose its missing-reason SARIF variant"
);
}
if counts {
counted.insert(result_key);
} else {
advisory.insert(result_key);
}
} else {
assert!(
!counts,
"dead-code row {} counts in total_issues but has no result_key",
row["id"]
);
}
}
assert_eq!(expected, counted);
assert_eq!(
FxHashSet::from_iter([
"duplicate_prop_shapes",
"prop_drilling_chains",
"thin_wrappers",
]),
advisory
);
}
#[test]
fn dead_code_codeclimate_contract_is_explicit() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let missing: FxHashSet<&str> = rows
.iter()
.filter(|row| row["command"].as_str() == Some("dead-code"))
.filter(|row| row["result_key"].as_str().is_some())
.filter(|row| row["codeclimate_check_names"].is_null())
.map(|row| row["id"].as_str().unwrap())
.collect();
assert_eq!(
FxHashSet::from_iter(["duplicate-prop-shape", "prop-drilling", "thin-wrapper"]),
missing
);
for row in rows {
if row["command"].as_str() == Some("dead-code")
&& row["codeclimate_check_names"].as_array().is_some()
{
let codeclimate_check_names: FxHashSet<&str> = row["codeclimate_check_names"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap())
.collect();
assert!(
codeclimate_check_names.contains(row["rule_id"].as_str().unwrap()),
"dead-code row {} must include the rule id in CodeClimate check names",
row["id"]
);
if row["id"].as_str() == Some("stale-suppression") {
assert!(
codeclimate_check_names.contains("fallow/missing-suppression-reason"),
"stale-suppression must expose its missing-reason CodeClimate variant"
);
}
}
}
}
#[test]
fn dead_code_ts_alias_contract_is_explicit() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let aliases: FxHashSet<(String, String, String)> = rows
.iter()
.filter(|row| row["command"].as_str() == Some("dead-code"))
.filter_map(|row| {
let alias = row["ts_alias"].as_object()?;
Some((
row["id"].as_str().unwrap().to_string(),
alias["name"].as_str().unwrap().to_string(),
alias["parent"].as_str().unwrap().to_string(),
))
})
.collect();
let expected: FxHashSet<(String, String, String)> = issue_output_contracts()
.filter_map(|contract| {
let alias = contract.ts_alias?;
Some((
contract.code.to_string(),
alias.name.to_string(),
alias.parent.to_string(),
))
})
.collect();
assert!(aliases.contains(&(
"unused-dependency".to_string(),
"UnusedDependency".to_string(),
"UnusedDependencyFinding".to_string()
)));
assert!(aliases.contains(&(
"unused-dev-dependency".to_string(),
"UnusedDependency".to_string(),
"UnusedDevDependencyFinding".to_string()
)));
assert!(aliases.contains(&(
"unused-optional-dependency".to_string(),
"UnusedDependency".to_string(),
"UnusedOptionalDependencyFinding".to_string()
)));
assert!(aliases.contains(&(
"unused-class-member".to_string(),
"UnusedMember".to_string(),
"UnusedClassMemberFinding".to_string()
)));
assert!(aliases.contains(&(
"unused-enum-member".to_string(),
"UnusedMember".to_string(),
"UnusedEnumMemberFinding".to_string()
)));
assert!(aliases.contains(&(
"unused-store-member".to_string(),
"UnusedMember".to_string(),
"UnusedStoreMemberFinding".to_string()
)));
assert_eq!(
expected, aliases,
"schema ts_alias rows must exactly mirror fallow-output contracts"
);
for (_, name, parent) in aliases {
assert!(
name.chars().next().is_some_and(char::is_uppercase),
"TS alias name must be PascalCase: {name}"
);
assert!(
parent.ends_with("Finding"),
"TS alias parent must target a finding wrapper: {parent}"
);
}
}
#[test]
fn filter_flags_exist_on_live_clap_commands() {
let schema = schema();
let rows = schema["issue_types"].as_array().unwrap();
let dead_code_flags = subcommand_flags("dead-code");
let health_flags = subcommand_flags("health");
let mut seen_dead_code_filters: FxHashSet<&str> = FxHashSet::default();
for row in rows {
let Some(flag) = row["filter_flag"].as_str() else {
continue;
};
match row["command"].as_str().unwrap() {
"dead-code" => {
assert!(
DEAD_CODE_FILTER_FLAGS.contains(&flag),
"row {} filter_flag {flag} is not in the shared DEAD_CODE_FILTER_FLAGS list",
row["id"]
);
assert!(
dead_code_flags.contains(flag),
"row {} filter_flag {flag} does not exist on the dead-code subcommand",
row["id"]
);
seen_dead_code_filters
.insert(DEAD_CODE_FILTER_FLAGS.iter().find(|f| **f == flag).unwrap());
}
"health" => {
assert!(
health_flags.contains(flag),
"row {} filter_flag {flag} does not exist on the health subcommand",
row["id"]
);
}
other => panic!("unexpected filter_flag on command {other}"),
}
}
for flag in DEAD_CODE_FILTER_FLAGS.iter() {
assert!(
seen_dead_code_filters.contains(flag),
"shared filter flag {flag} is not represented by any issue_types row"
);
}
}
#[test]
fn mcp_tools_block_lists_every_manifest_tool() {
let schema = schema();
let block = &schema["mcp_tools"];
assert_eq!(block["server"], "fallow-mcp");
let tools = block["tools"].as_array().unwrap();
assert_eq!(tools.len(), MCP_TOOLS.len());
for tool in tools {
let obj = tool.as_object().unwrap();
for key in [
"name",
"kind",
"description",
"cli_command",
"key_params",
"license",
"license_note",
"read_only",
"code_mode_alias",
] {
assert!(
obj.contains_key(key),
"mcp tool {} missing key {key}",
tool["name"]
);
}
if tool["license"] == "freemium" {
assert!(
tool["license_note"].is_string(),
"freemium tool {} must carry a license_note",
tool["name"]
);
}
}
let code_execute = tools
.iter()
.find(|t| t["name"] == "code_execute")
.expect("code_execute in mcp_tools");
assert_eq!(code_execute["kind"], "composition");
assert!(code_execute["cli_command"].is_null());
let analyze = tools
.iter()
.find(|t| t["name"] == "analyze")
.expect("analyze in mcp_tools");
assert_eq!(
analyze["cli_command"],
"fallow dead-code --format json --quiet"
);
}
#[test]
fn mcp_tools_block_projects_the_code_mode_allowlist() {
let schema = schema();
let tools = schema["mcp_tools"]["tools"].as_array().unwrap();
for (row, manifest) in tools.iter().zip(MCP_TOOLS) {
assert_eq!(row["name"], manifest.name);
assert_eq!(
row["code_mode_alias"].as_str(),
manifest.code_mode_alias,
"code_mode_alias for {} diverges from the shared manifest",
manifest.name
);
}
for name in [
"code_execute",
"fix_preview",
"fix_apply",
"find_similar_code",
"inspect_similar_code",
] {
let row = tools
.iter()
.find(|row| row["name"] == name)
.unwrap_or_else(|| panic!("{name} in mcp_tools"));
assert!(
row["code_mode_alias"].is_null(),
"{name} must advertise itself as unreachable from Code Mode"
);
}
let exposed: Vec<&str> = tools
.iter()
.filter_map(|row| row["code_mode_alias"].as_str())
.collect();
assert!(exposed.contains(&"checkHealth"));
assert!(exposed.contains(&"explain"));
}
#[test]
fn mcp_resources_block_lists_every_manifest_resource() {
let schema = schema();
let block = &schema["mcp_resources"];
assert_eq!(block["server"], "fallow-mcp");
assert!(block["note"].is_string());
let resources = block["resources"].as_array().unwrap();
assert_eq!(resources.len(), MCP_RESOURCES.len());
for (row, manifest) in resources.iter().zip(MCP_RESOURCES) {
let obj = row.as_object().unwrap();
for key in ["uri", "name", "description", "mime_type", "template"] {
assert!(
obj.contains_key(key),
"mcp resource {} missing key {key}",
row["uri"]
);
}
assert_eq!(row["uri"], manifest.uri);
assert_eq!(row["name"], manifest.name);
assert_eq!(row["mime_type"], manifest.mime_type);
assert_eq!(row["template"], manifest.template);
}
let template = resources
.iter()
.find(|r| r["uri"] == "fallow://explain/{issue_type}")
.expect("explain template in mcp_resources");
assert_eq!(template["template"], true);
}
#[test]
fn plugins_block_reflects_live_registry() {
let schema = schema();
let block = &schema["plugins"];
let names = block["names"].as_array().unwrap();
let count = usize::try_from(block["count"].as_u64().unwrap()).unwrap();
assert_eq!(names.len(), count);
assert_eq!(
count,
fallow_engine::plugins::registry::builtin_plugin_names().len()
);
assert!(count >= 110, "plugin registry shrank unexpectedly");
}
#[test]
fn schema_output_formats_include_all_formats() {
let schema = schema();
let formats = schema["output_formats"].as_array().unwrap();
for expected in [
"human",
"json",
"sarif",
"compact",
"markdown",
"md",
"codeclimate",
"gitlab-codequality",
"gitlab-code-quality",
"pr-comment-github",
"pr-comment-gitlab",
"review-github",
"review-gitlab",
"badge",
"github-annotations",
"github-summary",
] {
assert!(
formats.iter().any(|f| f.as_str().unwrap() == expected),
"missing format: {expected}"
);
}
}
#[test]
fn schema_severity_levels() {
let schema = schema();
let levels = schema["severity_levels"].as_array().unwrap();
for expected in ["error", "warn", "off"] {
assert!(
levels.iter().any(|l| l.as_str().unwrap() == expected),
"missing severity level: {expected}"
);
}
}
#[test]
fn build_arg_schema_bool_type() {
let cmd = Cli::command();
let quiet_arg = cmd.get_arguments().find(|a| a.get_id() == "quiet").unwrap();
let schema = build_arg_schema(quiet_arg);
assert_eq!(schema["type"], "bool");
}
#[test]
fn build_arg_schema_includes_short_flag() {
let cmd = Cli::command();
let quiet_arg = cmd.get_arguments().find(|a| a.get_id() == "quiet").unwrap();
let schema = build_arg_schema(quiet_arg);
if quiet_arg.get_short().is_some() {
assert!(schema["short"].is_string());
}
}
fn global_flag_longs() -> FxHashSet<String> {
Cli::command()
.get_arguments()
.filter_map(|a| a.get_long().map(|l| format!("--{l}")))
.collect()
}
#[test]
fn schema_has_task_matrix() {
let schema = schema();
let rows = schema["task_matrix"].as_array().unwrap();
assert!(!rows.is_empty(), "task_matrix must have at least one row");
for row in rows {
let obj = row.as_object().unwrap();
for key in ["task", "command", "note"] {
assert!(obj.contains_key(key), "task_matrix row missing key {key}");
}
assert!(obj["task"].is_string());
assert!(obj["command"].is_string());
}
}
#[test]
fn task_matrix_commands_parse_through_clap() {
use clap::Parser;
for row in crate::task_matrix::TASK_MATRIX {
if row.probe.is_empty() {
continue;
}
let argv = std::iter::once("fallow").chain(row.probe.iter().copied());
Cli::try_parse_from(argv).unwrap_or_else(|e| {
panic!(
"task matrix probe {:?} for command '{}' does not parse: {e}",
row.probe, row.command
)
});
}
}
#[test]
fn task_matrix_excludes_mutating_commands() {
for row in crate::task_matrix::TASK_MATRIX {
let after_fallow = row.command.strip_prefix("fallow ").unwrap_or(row.command);
let first_token = after_fallow.split_whitespace().next().unwrap_or("");
assert!(
!fallow_types::task_matrix::MUTATING_COMMANDS.contains(&first_token),
"task matrix command '{}' names mutating token '{first_token}'",
row.command
);
}
}
#[test]
fn task_matrix_workspace_flags_are_global() {
let longs = global_flag_longs();
for flag in ["--workspace", "--changed-workspaces"] {
assert!(
longs.contains(flag),
"{flag} is not a global flag on the root command"
);
}
}
}