use super::ast::*;
type SemanticCheckTable<'a> = &'a [(fn(&str) -> bool, &'a str, IssueSeverity, &'a str, &'a str)];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueSeverity {
Critical,
High,
Medium,
Low,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SemanticIssue {
pub message: String,
pub severity: IssueSeverity,
pub span: Span,
pub rule: String,
pub suggestion: Option<String>,
}
pub fn detect_shell_date(value: &str) -> bool {
if let Some(pos) = value.find("$(shell date") {
let after_date = pos + "$(shell date".len();
if after_date >= value.len() {
return true; }
let next_char = value.as_bytes()[after_date] as char;
next_char.is_whitespace() || next_char == ')' || next_char == '+' || next_char == '-'
} else {
false
}
}
pub fn detect_wildcard(value: &str) -> bool {
if !value.contains("$(wildcard") {
return false;
}
if value.contains("$(sort $(wildcard") {
return false;
}
true
}
const COMMON_PHONY_TARGETS: &[&str] =
&["test", "clean", "install", "deploy", "build", "all", "help"];
pub fn detect_random(value: &str) -> bool {
value.contains("$RANDOM") || value.contains("$$RANDOM")
}
pub fn detect_shell_find(value: &str) -> bool {
if !value.contains("$(shell find") {
return false;
}
if value.contains("$(sort $(shell find") {
return false;
}
true
}
pub fn is_common_phony_target(target_name: &str) -> bool {
COMMON_PHONY_TARGETS.contains(&target_name)
}
fn check_variable_determinism(
name: &str,
value: &str,
span: Span,
issues: &mut Vec<SemanticIssue>,
) {
let checks: SemanticCheckTable<'_> = &[
(
detect_shell_date,
"uses non-deterministic $(shell date) - replace with explicit version",
IssueSeverity::Critical,
"NO_TIMESTAMPS",
"1.0.0",
),
(
detect_wildcard,
"uses non-deterministic $(wildcard) - replace with explicit sorted file list",
IssueSeverity::High,
"NO_WILDCARD",
"file1.c file2.c file3.c",
),
(
detect_shell_find,
"uses non-deterministic $(shell find) - replace with explicit sorted file list",
IssueSeverity::High,
"NO_UNORDERED_FIND",
"src/a.c src/b.c src/main.c",
),
(
detect_random,
"uses non-deterministic $RANDOM - replace with fixed value or seed",
IssueSeverity::Critical,
"NO_RANDOM",
"42",
),
];
for (detect_fn, msg, severity, rule, suggestion) in checks {
if detect_fn(value) {
issues.push(SemanticIssue {
message: format!("Variable '{}' {}", name, msg),
severity: severity.clone(),
span,
rule: rule.to_string(),
suggestion: Some(format!("{} := {}", name, suggestion)),
});
}
}
}
pub fn analyze_makefile(ast: &MakeAst) -> Vec<SemanticIssue> {
let mut issues = Vec::new();
for item in &ast.items {
match item {
MakeItem::Variable {
name, value, span, ..
} => {
check_variable_determinism(name, value, *span, &mut issues);
}
MakeItem::Target {
name, phony, span, ..
} => {
if !phony && is_common_phony_target(name) {
issues.push(SemanticIssue {
message: format!(
"Target '{}' should be marked as .PHONY (common non-file target)",
name
),
severity: IssueSeverity::High,
span: *span,
rule: "AUTO_PHONY".to_string(),
suggestion: Some(format!(".PHONY: {}", name)),
});
}
}
_ => {}
}
}
issues
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_FUNC_SHELL_001_detect_shell_date_basic() {
assert!(detect_shell_date("$(shell date +%s)"));
}
#[test]
fn test_FUNC_SHELL_001_detect_shell_date_with_format() {
assert!(detect_shell_date("$(shell date +%Y%m%d-%H%M%S)"));
}
#[test]
fn test_FUNC_SHELL_001_no_false_positive() {
assert!(!detect_shell_date("VERSION := 1.0.0"));
}
#[test]
fn test_FUNC_SHELL_001_detect_in_variable_context() {
let value = "RELEASE := $(shell date +%s)";
assert!(detect_shell_date(value));
}
#[test]
fn test_FUNC_SHELL_001_empty_string() {
assert!(!detect_shell_date(""));
}
#[test]
fn test_FUNC_SHELL_001_no_shell_command() {
assert!(!detect_shell_date("$(CC) -o output"));
}
#[test]
fn test_FUNC_SHELL_001_shell_but_not_date() {
assert!(!detect_shell_date("$(shell pwd)"));
}
#[test]
fn test_FUNC_SHELL_001_multiple_shell_commands() {
assert!(detect_shell_date("A=$(shell pwd) B=$(shell date +%s)"));
}
#[test]
fn test_FUNC_SHELL_001_date_without_shell() {
assert!(!detect_shell_date("# Update date: 2025-10-16"));
}
#[test]
fn test_FUNC_SHELL_001_case_sensitive() {
assert!(!detect_shell_date("$(SHELL DATE)"));
}
#[test]
fn test_FUNC_SHELL_001_mut_contains_must_check_substring() {
assert!(detect_shell_date("prefix $(shell date +%s) suffix"));
}
#[test]
fn test_FUNC_SHELL_001_mut_exact_pattern() {
assert!(!detect_shell_date("datestamp"));
}
#[test]
fn test_FUNC_SHELL_001_mut_non_empty_check() {
let result = detect_shell_date("");
assert!(!result);
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_FUNC_SHELL_001_any_string_no_panic(s in "\\PC*") {
let _ = detect_shell_date(&s);
}
#[test]
fn prop_FUNC_SHELL_001_shell_date_always_detected(
format in "[+%a-zA-Z0-9-]*"
) {
let input = format!("$(shell date {})", format);
prop_assert!(detect_shell_date(&input));
}
#[test]
fn prop_FUNC_SHELL_001_no_shell_never_detected(
s in "[^$]*"
) {
prop_assert!(!detect_shell_date(&s));
}
#[test]
fn prop_FUNC_SHELL_001_deterministic(s in "\\PC*") {
let result1 = detect_shell_date(&s);
let result2 = detect_shell_date(&s);
prop_assert_eq!(result1, result2);
}
#[test]
fn prop_FUNC_SHELL_001_shell_without_date_not_detected(
cmd in "[a-z]{3,10}"
) {
if cmd != "date" {
let input = format!("$(shell {})", cmd);
prop_assert!(!detect_shell_date(&input));
}
}
}
}
#[test]
fn test_FUNC_SHELL_001_analyze_detects_shell_date() {
use crate::make_parser::parse_makefile;
}
}
include!("semantic_part2_incl2.rs");