#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn handle_analyze_makefile(
path: PathBuf,
rules: Vec<String>,
format: MakefileOutputFormat,
fix: bool,
gnu_version: Option<String>,
top_files: usize,
) -> Result<()> {
use crate::services::makefile_linter;
crate::status_eprintln!("🔧 Analyzing Makefile...");
if !path.exists() {
return Err(anyhow::anyhow!("Makefile not found: {}", path.display()));
}
validate_rule_names(&rules)?;
let lint_result = makefile_linter::lint_makefile(&path)
.await
.map_err(|e| anyhow::anyhow!("Makefile linting failed: {e}"))?;
let mut measured = lint_result.violations.clone();
if let Some(ref requested) = gnu_version {
let source = std::fs::read_to_string(&path)?;
let mut incompat = check_gnu_version_compatibility(&source, requested)?;
crate::status_eprintln!(
"📌 {} construct(s) newer than GNU Make {requested}",
incompat.len()
);
measured.append(&mut incompat);
}
let outcome = apply_rule_filter(&measured, &rules);
print_makefile_analysis_summary(&lint_result, &outcome);
let content = format_makefile_output(
&path,
&outcome,
&lint_result,
gnu_version.as_ref(),
format,
top_files,
)?;
println!("{content}");
handle_makefile_fix_mode(fix, &outcome.kept);
Ok(())
}
fn validate_rule_names(rules: &[String]) -> Result<()> {
if rules.iter().any(|r| r.trim().is_empty()) {
return Err(anyhow::anyhow!(
"--rules contains an empty rule name; pass rule ids (e.g. \
security/shell-injection, undefinedvariable) or omit --rules for all rules"
));
}
if rules.iter().any(|r| r.trim() == "all") {
return Ok(());
}
let valid = valid_rule_names();
let unknown: Vec<&str> = rules
.iter()
.map(|r| r.trim())
.filter(|r| !valid.iter().any(|v| v == r))
.collect();
if !unknown.is_empty() {
return Err(anyhow::anyhow!(
"--rules names no such rule: {}. Valid rule ids are: {}. \
Omit --rules (or pass `all`) to run every rule.",
unknown.join(", "),
valid.join(", ")
));
}
Ok(())
}
fn valid_rule_names() -> Vec<String> {
let registry = makefile_linter::RuleRegistry::new();
let mut names: Vec<String> = registry
.rule_ids()
.into_iter()
.map(ToString::to_string)
.collect();
names.push(GNU_VERSION_RULE.to_string());
names.push("all".to_string());
names.sort();
names.dedup();
names
}
struct RuleFilterOutcome {
kept: Vec<makefile_linter::Violation>,
measured: usize,
filter: Vec<String>,
rules_present: Vec<String>,
}
impl RuleFilterOutcome {
fn filtered_everything_away(&self) -> bool {
!self.filter.is_empty() && self.kept.is_empty() && self.measured > 0
}
fn filter_display(&self) -> String {
self.filter.join(",")
}
}
fn apply_rule_filter(
violations: &[makefile_linter::Violation],
rules: &[String],
) -> RuleFilterOutcome {
let no_filter = rules.is_empty() || rules.iter().any(|r| r.trim() == "all");
let kept = if no_filter {
violations.to_vec()
} else {
violations
.iter()
.filter(|v| rules.contains(&v.rule))
.cloned()
.collect()
};
let rules_present: Vec<String> = violations
.iter()
.map(|v| v.rule.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
RuleFilterOutcome {
kept,
measured: violations.len(),
filter: if no_filter { Vec::new() } else { rules.to_vec() },
rules_present,
}
}
const GNU_VERSION_RULE: &str = "gnuversion";
const GNU_MAKE_FEATURES: &[(&str, &str)] = &[
(".ONESHELL:", "3.82"),
(".NOTINTERMEDIATE:", "4.4"),
("::=", "4.0"),
("!=", "4.0"),
("$(file ", "4.0"),
("$(intcmp ", "4.4"),
("$(let ", "4.4"),
];
fn parse_gnu_version(version: &str) -> Result<(u32, u32)> {
let mut parts = version.trim().split('.');
let major = parts
.next()
.and_then(|p| p.parse::<u32>().ok())
.ok_or_else(|| anyhow::anyhow!("invalid --gnu-version '{version}': expected e.g. 4.4"))?;
let minor = parts.next().unwrap_or("0").parse::<u32>().unwrap_or(0);
Ok((major, minor))
}
fn uses_make_construct(line: &str, token: &str) -> bool {
if line.starts_with('\t') {
return false;
}
let trimmed = line.trim_start();
match token {
"!=" | "::=" => match trimmed.find(token) {
Some(pos) => {
let lhs = trimmed.get(..pos).unwrap_or_default().trim_end();
!lhs.is_empty()
&& lhs
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
}
None => false,
},
_ => trimmed.contains(token),
}
}
fn check_gnu_version_compatibility(
source: &str,
requested: &str,
) -> Result<Vec<makefile_linter::Violation>> {
use crate::services::makefile_linter::ast::SourceSpan;
let target = parse_gnu_version(requested)?;
let mut violations = Vec::new();
for (idx, line) in source.lines().enumerate() {
for (token, introduced_in) in GNU_MAKE_FEATURES {
let needs = parse_gnu_version(introduced_in)?;
if needs <= target || !uses_make_construct(line, token) {
continue;
}
let column = line.find(token).unwrap_or(0) + 1;
violations.push(makefile_linter::Violation {
rule: GNU_VERSION_RULE.to_string(),
severity: makefile_linter::Severity::Warning,
span: SourceSpan {
start: 0,
end: 0,
line: idx + 1,
column,
},
message: format!(
"`{token}` requires GNU Make {introduced_in}, but compatibility was requested against {requested}"
),
fix_hint: None,
});
}
}
Ok(violations)
}
fn print_makefile_analysis_summary(
lint_result: &makefile_linter::LintResult,
outcome: &RuleFilterOutcome,
) {
if outcome.filter.is_empty() {
crate::status_eprintln!("📊 Found {} violations", outcome.measured);
} else {
crate::status_eprintln!(
"📊 Found {} violations; {} match --rules {}",
outcome.measured,
outcome.kept.len(),
outcome.filter_display()
);
}
crate::status_eprintln!(
"✨ Quality score: {:.1}% ({})",
lint_result.quality_score * 100.0,
score_scope(lint_result, outcome)
);
}
fn score_scope(
lint_result: &makefile_linter::LintResult,
outcome: &RuleFilterOutcome,
) -> String {
let scored = lint_result.violations.len();
let appended = outcome.measured.saturating_sub(scored);
if appended == 0 {
return format!("over all {scored} violation(s) found");
}
format!(
"over the {scored} violation(s) the linter measured; {appended} further \
--gnu-version finding(s) are not in the score"
)
}
fn handle_makefile_fix_mode(fix: bool, filtered_violations: &[makefile_linter::Violation]) {
if !fix {
return;
}
let fixable_violations: Vec<_> = filtered_violations
.iter()
.filter(|v| v.fix_hint.is_some())
.collect();
if fixable_violations.is_empty() {
eprintln!("\n💡 No automatically fixable violations found.");
return;
}
eprintln!("\n🔧 Fixable violations (suggestions only — no file was modified):");
let fix_count = fixable_violations.len();
for violation in fixable_violations {
if let Some(fix_hint) = &violation.fix_hint {
eprintln!(" • {}: {}", violation.rule, fix_hint);
}
}
eprintln!("💡 {fix_count} fixable violation(s); applying them automatically is not implemented, so the Makefile is unchanged.");
}
fn format_makefile_output(
path: &Path,
outcome: &RuleFilterOutcome,
lint_result: &makefile_linter::LintResult,
gnu_version: Option<&String>,
format: MakefileOutputFormat,
top_files: usize,
) -> Result<String> {
let listed = crate::cli::top_files_slice(&outcome.kept, top_files);
let total = outcome.kept.len();
match format {
MakefileOutputFormat::Json => {
format_makefile_as_json(path, listed, total, outcome, lint_result, gnu_version)
}
MakefileOutputFormat::Human => format_makefile_as_human(
path,
listed,
total,
top_files,
outcome,
lint_result,
gnu_version,
),
MakefileOutputFormat::Sarif => format_makefile_as_sarif(path, listed),
MakefileOutputFormat::Gcc => format_makefile_as_gcc(path, listed),
}
}
fn format_makefile_as_json(
path: &Path,
listed: &[makefile_linter::Violation],
total: usize,
outcome: &RuleFilterOutcome,
lint_result: &makefile_linter::LintResult,
gnu_version: Option<&String>,
) -> Result<String> {
Ok(serde_json::to_string_pretty(&serde_json::json!({
"path": path.display().to_string(),
"violations": listed,
"violations_total": total,
"violations_listed": listed.len(),
"violations_truncated": listed.len() < total,
"violations_measured": outcome.measured,
"rules_filter": outcome.filter,
"rules_present": outcome.rules_present,
"quality_score": lint_result.quality_score,
"gnu_version": gnu_version,
}))?)
}
#[allow(clippy::too_many_arguments)]
fn format_makefile_as_human(
path: &Path,
listed: &[makefile_linter::Violation],
total: usize,
top_files: usize,
outcome: &RuleFilterOutcome,
lint_result: &makefile_linter::LintResult,
gnu_version: Option<&String>,
) -> Result<String> {
let mut output = String::new();
write_makefile_human_header(&mut output, path, lint_result, gnu_version, outcome)?;
write_makefile_violations_table(&mut output, listed, total, top_files, outcome)?;
write_makefile_fix_suggestions(&mut output, listed)?;
Ok(output)
}
fn write_makefile_human_header(
output: &mut String,
path: &Path,
lint_result: &makefile_linter::LintResult,
gnu_version: Option<&String>,
outcome: &RuleFilterOutcome,
) -> Result<()> {
use std::fmt::Write;
writeln!(output, "# Makefile Analysis Report\n")?;
writeln!(output, "**File**: {}", path.display())?;
writeln!(
output,
"**Quality Score**: {:.1}% ({})",
lint_result.quality_score * 100.0,
score_scope(lint_result, outcome)
)?;
if !outcome.filter.is_empty() {
writeln!(
output,
"**Rules Filter**: {} — {} of {} violation(s) match",
outcome.filter_display(),
outcome.kept.len(),
outcome.measured
)?;
}
if let Some(ver) = gnu_version {
writeln!(output, "**GNU Make Version**: {ver}")?;
}
writeln!(output)?;
Ok(())
}
fn write_makefile_violations_table(
output: &mut String,
listed: &[makefile_linter::Violation],
total: usize,
top_files: usize,
outcome: &RuleFilterOutcome,
) -> Result<()> {
use std::fmt::Write;
if listed.is_empty() {
if outcome.filtered_everything_away() {
writeln!(
output,
"⚠️ {} violation(s) found; 0 match --rules {}.",
outcome.measured,
outcome.filter_display()
)?;
writeln!(
output,
"\nRule(s) that fired on this Makefile: {}",
outcome.rules_present.join(", ")
)?;
writeln!(
output,
"(no requested name matched — check the spelling of --rules)"
)?;
} else {
writeln!(output, "✅ No violations found!")?;
}
} else {
writeln!(output, "## Violations\n")?;
writeln!(output, "| Line | Rule | Severity | Message |")?;
writeln!(output, "|------|------|----------|---------|")?;
for violation in listed {
let severity = get_severity_display(&violation.severity);
writeln!(
output,
"| {} | {} | {} | {} |",
violation.span.line,
violation.rule,
severity,
violation.message.replace('|', "\\|")
)?;
}
if listed.len() < total {
writeln!(
output,
"\n… {} more not shown (--top-files {top_files}, 0 = all)",
total - listed.len()
)?;
}
}
Ok(())
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn get_severity_display(severity: &makefile_linter::Severity) -> &'static str {
match severity {
makefile_linter::Severity::Error => "❌ Error",
makefile_linter::Severity::Warning => "⚠️ Warning",
makefile_linter::Severity::Performance => "⚡ Performance",
makefile_linter::Severity::Info => "ℹ️ Info",
}
}
fn write_makefile_fix_suggestions(
output: &mut String,
filtered_violations: &[makefile_linter::Violation],
) -> Result<()> {
use std::fmt::Write;
let violations_with_fixes: Vec<_> = filtered_violations
.iter()
.filter(|v| v.fix_hint.is_some())
.collect();
if !violations_with_fixes.is_empty() {
writeln!(output, "\n## Fix Suggestions\n")?;
for violation in violations_with_fixes {
writeln!(
output,
"**Line {}** ({}): {}",
violation.span.line,
violation.rule,
violation
.fix_hint
.as_ref()
.expect("fix_hint must be present when accessed")
)?;
}
}
Ok(())
}
fn format_makefile_as_sarif(
path: &Path,
filtered_violations: &[makefile_linter::Violation],
) -> Result<String> {
let sarif = serde_json::json!({
"version": "2.1.0",
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"runs": [{
"tool": {
"driver": {
"name": "paiml-makefile-linter",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/paiml/paiml-mcp-agent-toolkit",
"rules": build_sarif_rules(filtered_violations)
}
},
"results": build_sarif_results(path, filtered_violations)
}]
});
Ok(serde_json::to_string_pretty(&sarif)?)
}
fn build_sarif_rules(filtered_violations: &[makefile_linter::Violation]) -> Vec<serde_json::Value> {
filtered_violations
.iter()
.map(|v| &v.rule)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.map(|rule| {
serde_json::json!({
"id": rule,
"name": rule,
"defaultConfiguration": {
"level": "warning"
}
})
})
.collect::<Vec<_>>()
}
fn build_sarif_results(
path: &Path,
filtered_violations: &[makefile_linter::Violation],
) -> Vec<serde_json::Value> {
filtered_violations
.iter()
.map(|violation| {
let level = get_sarif_level(&violation.severity);
serde_json::json!({
"ruleId": &violation.rule,
"level": level,
"message": {
"text": &violation.message
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": path.display().to_string()
},
"region": {
"startLine": violation.span.line,
"startColumn": violation.span.column
}
}
}],
"fixes": violation.fix_hint.as_ref().map(|hint| vec![
serde_json::json!({
"description": {
"text": hint
}
})
])
})
})
.collect::<Vec<_>>()
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn get_sarif_level(severity: &makefile_linter::Severity) -> &'static str {
match severity {
makefile_linter::Severity::Error => "error",
makefile_linter::Severity::Warning => "warning",
makefile_linter::Severity::Performance => "note",
makefile_linter::Severity::Info => "note",
}
}
fn format_makefile_as_gcc(
path: &Path,
filtered_violations: &[makefile_linter::Violation],
) -> Result<String> {
use std::fmt::Write;
let mut output = String::new();
for violation in filtered_violations {
writeln!(
&mut output,
"{}:{}:{}: {}: {} [{}]",
path.display(),
violation.span.line,
violation.span.column,
get_gcc_level(&violation.severity),
violation.message,
violation.rule
)?;
}
Ok(output)
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn get_gcc_level(severity: &makefile_linter::Severity) -> &'static str {
match severity {
makefile_linter::Severity::Error => "error",
makefile_linter::Severity::Warning => "warning",
makefile_linter::Severity::Performance => "note",
makefile_linter::Severity::Info => "note",
}
}
#[cfg(test)]
mod makefile_gnu_version_tests {
use super::*;
const MODERN: &str =
".ONESHELL:\nX ::= hello\nY != echo dynamic\n.PHONY: all\nall:\n\t@echo hi\n";
#[test]
fn test_old_gnu_version_reports_incompatible_constructs() {
let v = check_gnu_version_compatibility(MODERN, "3.0").unwrap();
let messages: Vec<&str> = v.iter().map(|x| x.message.as_str()).collect();
assert_eq!(
v.len(),
3,
"expected .ONESHELL:, ::= and != — got {messages:?}"
);
assert!(messages.iter().any(|m| m.contains(".ONESHELL:")));
assert!(messages.iter().any(|m| m.contains("::=")));
assert!(messages.iter().any(|m| m.contains("!=")));
assert!(v.iter().all(|x| x.rule == GNU_VERSION_RULE));
}
#[test]
fn test_new_gnu_version_reports_nothing() {
assert!(check_gnu_version_compatibility(MODERN, "4.4")
.unwrap()
.is_empty());
}
#[test]
fn test_gnu_version_boundaries() {
assert_eq!(
check_gnu_version_compatibility(MODERN, "3.81")
.unwrap()
.len(),
3
);
assert!(check_gnu_version_compatibility(MODERN, "4.0")
.unwrap()
.is_empty());
let let_fn = "X = $(let a,1,$(a))\n";
assert_eq!(
check_gnu_version_compatibility(let_fn, "4.0")
.unwrap()
.len(),
1
);
assert!(check_gnu_version_compatibility(let_fn, "4.4")
.unwrap()
.is_empty());
}
#[test]
fn test_shell_inequality_in_a_recipe_is_not_a_make_construct() {
let src = "all:\n\t@if [ \"$$a\" != \"$$b\" ]; then echo differ; fi\n";
assert!(check_gnu_version_compatibility(src, "3.0")
.unwrap()
.is_empty());
}
#[test]
fn test_bad_gnu_version_is_rejected() {
assert!(check_gnu_version_compatibility(MODERN, "banana").is_err());
assert_eq!(parse_gnu_version("4").unwrap(), (4, 0));
assert_eq!(parse_gnu_version("3.81").unwrap(), (3, 81));
}
#[test]
fn test_fix_mode_does_not_claim_to_have_written() {
let violations = vec![makefile_linter::Violation {
rule: "phonydeclared".to_string(),
severity: makefile_linter::Severity::Warning,
span: crate::services::makefile_linter::ast::SourceSpan {
start: 0,
end: 0,
line: 1,
column: 1,
},
message: "target 'a' is not declared .PHONY".to_string(),
fix_hint: Some("Add 'a' to .PHONY declaration".to_string()),
}];
handle_makefile_fix_mode(true, &violations);
handle_makefile_fix_mode(false, &violations);
let claim = concat!("violations automatically", " fixed.");
let source = include_str!("makefile.rs");
assert!(
!source.contains(claim),
"--fix must not report violations as fixed while it writes no file"
);
assert!(source.contains("is not implemented, so the Makefile is unchanged"));
}
}
#[cfg(test)]
mod makefile_top_files_tests {
use super::*;
fn violations(n: usize) -> Vec<makefile_linter::Violation> {
(0..n)
.map(|i| makefile_linter::Violation {
rule: format!("rule{i}"),
severity: makefile_linter::Severity::Warning,
span: crate::services::makefile_linter::ast::SourceSpan {
start: 0,
end: 0,
line: i + 1,
column: 1,
},
message: format!("violation number {i}"),
fix_hint: Some(format!("fix number {i}")),
})
.collect()
}
fn lint_result() -> makefile_linter::LintResult {
makefile_linter::LintResult {
path: PathBuf::from("Makefile"),
violations: Vec::new(),
quality_score: 0.5,
}
}
fn render(
v: &[makefile_linter::Violation],
format: MakefileOutputFormat,
top: usize,
) -> String {
let outcome = apply_rule_filter(v, &[]);
format_makefile_output(
Path::new("Makefile"),
&outcome,
&lint_result(),
None,
format,
top,
)
.expect("render")
}
#[test]
fn the_limit_bites_in_every_format() {
let v = violations(12);
for format in [
MakefileOutputFormat::Human,
MakefileOutputFormat::Json,
MakefileOutputFormat::Gcc,
MakefileOutputFormat::Sarif,
] {
let one = render(&v, format.clone(), 1);
let fifty = render(&v, format.clone(), 50);
assert_ne!(
one, fifty,
"--top-files 1 and 50 rendered identically for {format:?}"
);
assert!(
one.contains("violation number 0") || one.contains("fix number 0"),
"the one row kept must be the first: {one}"
);
assert!(
!one.contains("violation number 11") && !one.contains("fix number 11"),
"--top-files 1 still printed row 12 for {format:?}"
);
}
}
#[test]
fn zero_means_every_row_not_none() {
let v = violations(12);
assert_eq!(render(&v, MakefileOutputFormat::Gcc, 0).lines().count(), 12);
assert_eq!(
render(&v, MakefileOutputFormat::Gcc, 0),
render(&v, MakefileOutputFormat::Gcc, 50)
);
}
#[test]
fn a_truncated_table_says_what_it_hid() {
let v = violations(12);
let one = render(&v, MakefileOutputFormat::Human, 1);
assert!(
one.contains("11 more not shown (--top-files 1, 0 = all)"),
"a capped table must name the rows it hid: {one}"
);
assert!(!render(&v, MakefileOutputFormat::Human, 50).contains("more not shown"));
}
#[test]
fn json_reports_the_total_it_measured_not_only_the_rows_it_listed() {
let v = violations(12);
let parsed: serde_json::Value =
serde_json::from_str(&render(&v, MakefileOutputFormat::Json, 3)).expect("json");
assert_eq!(parsed["violations_total"], 12);
assert_eq!(parsed["violations_listed"], 3);
assert_eq!(parsed["violations_truncated"], true);
assert_eq!(parsed["violations"].as_array().expect("array").len(), 3);
}
#[test]
fn sarif_rules_are_ordered_deterministically() {
let v = violations(12);
let first = render(&v, MakefileOutputFormat::Sarif, 0);
for _ in 0..8 {
assert_eq!(first, render(&v, MakefileOutputFormat::Sarif, 0));
}
}
}
#[cfg(test)]
mod makefile_rules_filter_tests {
use super::*;
fn three() -> Vec<makefile_linter::Violation> {
[
("security/shell-injection", makefile_linter::Severity::Error),
("undefinedvariable", makefile_linter::Severity::Warning),
("undefinedvariable", makefile_linter::Severity::Warning),
]
.into_iter()
.enumerate()
.map(|(i, (rule, severity))| makefile_linter::Violation {
rule: rule.to_string(),
severity,
span: crate::services::makefile_linter::ast::SourceSpan {
start: 0,
end: 0,
line: i + 1,
column: 1,
},
message: format!("violation {i}"),
fix_hint: None,
})
.collect()
}
fn lint_result() -> makefile_linter::LintResult {
makefile_linter::LintResult {
path: PathBuf::from("Makefile"),
violations: three(),
quality_score: 0.5,
}
}
fn render(rules: &[&str], format: MakefileOutputFormat) -> String {
let owned: Vec<String> = rules.iter().map(|r| (*r).to_string()).collect();
let outcome = apply_rule_filter(&three(), &owned);
format_makefile_output(
Path::new("Makefile"),
&outcome,
&lint_result(),
None,
format,
0,
)
.expect("render")
}
#[test]
fn an_unmatched_rules_value_does_not_report_a_clean_makefile() {
let human = render(&["nonexistent-rule-xyz"], MakefileOutputFormat::Human);
assert!(
!human.contains("No violations found"),
"a filter that matched nothing reported the Makefile clean:\n{human}"
);
assert!(
human.contains("3 violation(s) found; 0 match --rules nonexistent-rule-xyz"),
"the report must name both counts:\n{human}"
);
assert!(
human.contains("security/shell-injection"),
"the report must name the rules that did fire:\n{human}"
);
}
#[test]
fn the_quality_score_says_what_it_was_measured_over() {
let human = render(&["nonexistent-rule-xyz"], MakefileOutputFormat::Human);
assert!(
human.contains("**Quality Score**: 50.0% (over all 3 violation(s) found)"),
"{human}"
);
assert!(
human.contains("**Rules Filter**: nonexistent-rule-xyz — 0 of 3 violation(s) match"),
"{human}"
);
}
#[test]
fn the_score_caption_names_only_the_violations_it_covers() {
let mut outcome = apply_rule_filter(&three(), &[]);
assert_eq!(
score_scope(&lint_result(), &outcome),
"over all 3 violation(s) found"
);
outcome.measured = 5;
assert_eq!(
score_scope(&lint_result(), &outcome),
"over the 3 violation(s) the linter measured; 2 further --gnu-version \
finding(s) are not in the score"
);
let clean = makefile_linter::LintResult {
path: PathBuf::from("Makefile"),
violations: vec![],
quality_score: 1.0,
};
let mut empty = apply_rule_filter(&[], &[]);
empty.measured = 2;
assert_eq!(
score_scope(&clean, &empty),
"over the 0 violation(s) the linter measured; 2 further --gnu-version \
finding(s) are not in the score"
);
}
#[test]
fn json_carries_the_measured_total_and_the_filter() {
let parsed: serde_json::Value =
serde_json::from_str(&render(&["nonexistent-rule-xyz"], MakefileOutputFormat::Json))
.expect("json");
assert_eq!(parsed["violations_measured"], 3);
assert_eq!(parsed["violations_total"], 0);
assert_eq!(parsed["rules_filter"][0], "nonexistent-rule-xyz");
assert_eq!(parsed["rules_present"][0], "security/shell-injection");
assert_eq!(parsed["rules_present"][1], "undefinedvariable");
}
#[test]
fn a_matching_rule_still_filters_and_says_so() {
let human = render(&["undefinedvariable"], MakefileOutputFormat::Human);
assert!(human.contains("2 of 3 violation(s) match"), "{human}");
assert!(!human.contains("shell-injection"), "{human}");
assert!(!human.contains("0 match --rules"), "{human}");
}
#[test]
fn no_violations_at_all_still_reads_as_clean() {
let outcome = apply_rule_filter(&[], &["undefinedvariable".to_string()]);
assert!(!outcome.filtered_everything_away());
let mut out = String::new();
write_makefile_violations_table(&mut out, &[], 0, 0, &outcome).expect("write");
assert!(out.contains("✅ No violations found!"), "{out}");
}
#[test]
fn a_blank_rule_name_is_rejected() {
assert!(validate_rule_names(&["".to_string()]).is_err());
assert!(validate_rule_names(&[" ".to_string()]).is_err());
assert!(validate_rule_names(&["undefinedvariable".to_string()]).is_ok());
assert!(validate_rule_names(&[]).is_ok());
}
#[test]
fn all_and_empty_mean_no_filter() {
for rules in [
vec![],
vec!["all".to_string()],
vec!["all".to_string(), "undefinedvariable".to_string()],
] {
let outcome = apply_rule_filter(&three(), &rules);
assert_eq!(outcome.kept.len(), 3, "{rules:?}");
assert!(outcome.filter.is_empty(), "{rules:?}");
assert!(!outcome.filtered_everything_away(), "{rules:?}");
}
}
#[test]
fn an_unregistered_rule_name_is_rejected() {
let err = validate_rule_names(&["nonexistent-rule-xyz".to_string()])
.expect_err("a rule id no rule can emit must be rejected, not silently applied");
let message = err.to_string();
assert!(
message.contains("nonexistent-rule-xyz"),
"the error must name the offending value:\n{message}"
);
assert!(
message.contains("security/shell-injection") && message.contains("undefinedvariable"),
"the error must list the valid rule ids:\n{message}"
);
}
#[test]
fn every_registered_rule_id_is_an_accepted_rules_value() {
let registry = makefile_linter::RuleRegistry::new();
let ids = registry.rule_ids();
assert!(
ids.len() >= 11,
"the registry should expose every registered rule id, got {ids:?}"
);
for id in ids {
validate_rule_names(&[id.to_string()])
.unwrap_or_else(|e| panic!("registered rule '{id}' must be accepted: {e}"));
}
validate_rule_names(&["all".to_string()]).expect("all");
validate_rule_names(&[GNU_VERSION_RULE.to_string()]).expect("gnuversion");
}
#[test]
fn advertised_rule_ids_are_the_ids_violations_carry() {
let registry = makefile_linter::RuleRegistry::new();
let ids = registry.rule_ids();
for rule in ["security/shell-injection", "undefinedvariable", "minphony"] {
assert!(ids.contains(&rule), "{rule} missing from {ids:?}");
}
}
}