use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const FORBIDDEN: &[(&str, &str)] = &[
("G8-conditional", "\"conditional\""),
("G8-while_loop", "\"while_loop\""),
("G8-sequence", "\"sequence\""),
("F11-debug_assert", "debug_assert!"),
("F11-unwrap", ".unwrap("),
("F11-expect", ".expect("),
("F11-panic", "panic!("),
];
fn skip_requested(var: &str) -> bool {
match std::env::var(var) {
Ok(v) => {
let t = v.trim().to_ascii_lowercase();
if t == "1" || t == "true" {
match std::env::var("EVORULE_SKIP_REASON") {
Ok(r) if !r.trim().is_empty() => {
println!("cargo:warning={var} skip reason: {r}");
}
_ => {
println!(
"cargo:warning={var} 已跳过但未登记理由 (EVORULE_SKIP_REASON)——跳过须有书面理由"
);
}
}
true
} else {
println!("cargo:warning={var}={v} 非肯定值 (仅 1/true 生效),门禁照常执行");
false
}
}
Err(_) => false,
}
}
fn squeeze_ws(line: &str) -> String {
let mut out = String::with_capacity(line.len());
for c in line.chars() {
if !c.is_whitespace() {
out.push(c);
}
}
out
}
fn strip_inline_block_comments(line: &str) -> String {
let b = line.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0usize;
while i < b.len() {
let mut hashes = 0usize;
let mut raw = false;
let mut start_len = 0usize;
if b[i] == b'"' {
start_len = 1;
} else if b[i] == b'b' && i + 1 < b.len() && b[i + 1] == b'"' {
start_len = 2;
} else if b[i] == b'r' && i + 1 < b.len() && b[i + 1] == b'"' {
start_len = 2;
raw = true;
} else if b[i] == b'r' && i + 1 < b.len() && b[i + 1] == b'#'
|| b[i] == b'b' && i + 2 < b.len() && b[i + 1] == b'r' && b[i + 2] == b'#'
{
let mut j = i + if b[i] == b'b' { 2 } else { 1 };
while j < b.len() && b[j] == b'#' {
hashes += 1;
j += 1;
}
if j < b.len() && b[j] == b'"' {
raw = true;
start_len = j - i + 1;
}
}
if start_len > 0 {
let mut j = i + start_len;
let mut closed = false;
while j < b.len() {
if !raw && b[j] == b'\\' {
j += 2;
continue;
}
if b[j] == b'"' {
let mut k = j + 1;
let mut h = 0usize;
while k < b.len() && b[k] == b'#' {
h += 1;
k += 1;
}
if h == hashes {
closed = true;
j = k;
break;
}
}
j += 1;
}
if !closed {
return line.to_string(); }
out.extend_from_slice(&b[i..j]); i = j;
continue;
}
if b[i] == b'/' && i + 1 < b.len() && b[i + 1] == b'*' {
let mut depth = 1usize;
let mut j = i + 2;
while j < b.len() {
if b[j] == b'/' && j + 1 < b.len() && b[j + 1] == b'*' {
depth += 1;
j += 2;
} else if b[j] == b'*' && j + 1 < b.len() && b[j + 1] == b'/' {
depth -= 1;
j += 2;
if depth == 0 {
break;
}
} else {
j += 1;
}
}
if depth != 0 {
return line.to_string(); }
out.push(b' '); i = j;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| line.to_string())
}
fn bare_word_hit(hay: &str, needle: &str) -> bool {
if needle != "async" && needle != "await" {
return hay.contains(needle);
}
let hb = hay.as_bytes();
let mut from = 0usize;
while let Some(pos) = hay[from..].find(needle) {
let p = from + pos;
let before_ok = p == 0 || !hb[p - 1].is_ascii_alphabetic();
let end = p + needle.len();
let after_ok = end >= hb.len() || !hb[end].is_ascii_alphabetic();
if before_ok && after_ok {
return true;
}
from = p + 1;
}
false
}
fn main() -> ExitCode {
let crate_name = std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".into());
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=Cargo.toml");
if let Err(e) = detect_strategy_patterns(&crate_name) {
eprintln!("{}", e);
return ExitCode::FAILURE;
}
if skip_requested("EVORULE_SKIP_GATE") {
println!("cargo:warning={crate_name} compile-time gate SKIPPED via EVORULE_SKIP_GATE");
return ExitCode::SUCCESS;
}
let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
Ok(s) => PathBuf::from(s),
Err(_) => {
eprintln!("build.rs: CARGO_MANIFEST_DIR not set");
return ExitCode::FAILURE;
}
};
let src_dir = manifest_dir.join("src");
if !src_dir.exists() {
eprintln!("build.rs: src/ not found at {}", src_dir.display());
return ExitCode::FAILURE;
}
println!("cargo:rerun-if-changed={}", src_dir.display());
let mut rs_files: Vec<PathBuf> = Vec::new();
collect_rs_files(&src_dir, &mut rs_files);
for f in &rs_files {
println!("cargo:rerun-if-changed={}", f.display());
}
if rs_files.is_empty() {
eprintln!("==== {crate_name} compile-time gate FAILED ====");
eprintln!("No .rs files found under {}", src_dir.display());
return ExitCode::FAILURE;
}
let mut violations: Vec<(PathBuf, String, String)> = Vec::new();
for path in &rs_files {
let raw = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("build.rs: cannot read {}: {e}", path.display());
return ExitCode::FAILURE;
}
};
let content = strip_test_mod(&raw);
for (label, needle) in FORBIDDEN {
for (lineno, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
let stripped = strip_inline_block_comments(line);
let squeezed = squeeze_ws(&stripped);
if bare_word_hit(&stripped, needle) || bare_word_hit(&squeezed, needle) {
violations.push((
path.clone(),
label.to_string(),
format!("L{}: {}", lineno + 1, line.trim()),
));
}
}
}
}
if violations.is_empty() {
println!(
"cargo:warning={} 字面量门禁 PASSED - 已扫 {} 文件 x {} 模式, 0 违规",
crate_name,
rs_files.len(),
FORBIDDEN.len()
);
return ExitCode::SUCCESS;
}
eprintln!();
eprintln!("==== {crate_name} compile-time gate FAILED ====");
eprintln!("{} violation(s):", violations.len());
for (path, label, detail) in &violations {
eprintln!(" [{}] {}: {}", label, path.display(), detail);
}
eprintln!();
eprintln!("违规类型: G8=控制流指令字面量 | F11=panic-prone构造");
eprintln!("紧急跳过: EVORULE_SKIP_GATE=1 cargo build (须有书面理由)");
ExitCode::FAILURE
}
struct StrategyPattern<'a> {
label: &'static str,
patterns: &'a [&'static str],
description: &'static str,
}
const STRATEGY_PATTERNS: &[StrategyPattern<'static>] = &[
StrategyPattern {
label: "P1-business-domain",
patterns: &[
"\"hospital\"",
"\"medical\"",
"\"patient\"",
"\"clinic\"",
"\"finance\"",
"\"bank_\"",
"\"investment\"",
"\"loan_\"",
"\"lawyer\"",
"\"court_case\"",
"\"legal_document\"",
"\"insurance\"",
"\"policy_number\"",
"\"premium_amount\"",
"\"manufacturing\"",
"\"production_line\"",
"\"ecommerce\"",
"\"order_item\"",
"\"payment_method\"",
],
description: "机制层包含特定业务领域关键字,策略层逻辑必须在应用层仓实现",
},
StrategyPattern {
label: "P2-control-flow-hardcode",
patterns: &[
"execute_conditional",
"execute_while_loop",
"execute_sequence",
"handle_conditional",
"handle_while_loop",
"handle_sequence",
"process_conditional",
"process_while_loop",
"process_sequence",
],
description: "控制流指令的执行逻辑应在 core_eval.json 中定义,不应在 Rust 代码中实现",
},
StrategyPattern {
label: "P3-business-operation",
patterns: &[
"calculate_fee",
"calculate_tax",
"calculate_discount",
"validate_insurance",
"process_claim",
"approve_loan",
"check_credit",
"verify_identity",
"assess_risk",
"generate_invoice",
"create_order",
"process_payment",
"update_inventory",
"ship_product",
"receive_goods",
"hire_employee",
"pay_salary",
"calculate_bonus",
],
description: "机制层包含特定业务操作,操作逻辑应在应用层实现",
},
StrategyPattern {
label: "P4-business-rule-name",
patterns: &[
"hipaa_rule",
"gdpr_rule",
"pci_rule",
"sox_rule",
"compliance_check",
"audit_rule",
"regulatory_check",
"kpl_rule",
"aml_rule",
"kyc_rule",
],
description: "机制层包含特定业务规则名,规则定义应在应用层实现",
},
];
fn detect_strategy_patterns(crate_name: &str) -> Result<(), String> {
let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
Ok(s) => PathBuf::from(s),
Err(_) => {
return Err("build.rs: CARGO_MANIFEST_DIR not set".to_string());
}
};
let src_dir = manifest_dir.join("src");
if !src_dir.exists() {
return Ok(()); }
let mut rs_files = Vec::new();
collect_rs_files_for_strategy(&src_dir, &mut rs_files);
let mut violations: Vec<(PathBuf, String, String)> = Vec::new();
for path in &rs_files {
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => continue,
};
for pattern_def in STRATEGY_PATTERNS {
for pattern in pattern_def.patterns {
if content.contains(pattern) {
violations.push((
path.clone(),
pattern_def.label.to_string(),
format!(
"发现策略层反模式 \"{}\": {} (模式: {})",
pattern, pattern_def.description, pattern_def.label
),
));
}
}
}
}
if !violations.is_empty() {
let violation_details: Vec<String> = violations
.iter()
.map(|(path, label, detail)| format!(" [{}] {}: {}", label, path.display(), detail))
.collect();
return Err(format!(
"==== {} 策略层检测 FAILED ====\n\
\n\
检测到 {} 处策略层反模式。evorule 仓是机制层,不允许包含策略层代码。\n\
策略层逻辑必须在应用层仓(evorule-server/evorule-application 等)实现。\n\
\n\
违规详情:\n\
{}\n\
\n\
判定标准:\n\
- P1: 业务领域关键字(hospital/finance/legal 等)\n\
- P2: 控制流指令硬编码(conditional/while_loop/sequence 应在 core_eval.json)\n\
- P3: 业务操作硬编码(calculate_fee/process_claim 等)\n\
- P4: 业务规则名硬编码(hipaa_rule/gdpr_rule 等)\n\
\n\
若确认此变更是机制层变更,请检查代码中是否意外引入了策略层概念。",
crate_name,
violations.len(),
violation_details.join("\n")
));
}
println!(
"cargo:warning={} 策略层检测 PASSED - 未发现策略层反模式",
crate_name
);
Ok(())
}
fn collect_rs_files_for_strategy(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
collect_rs_files_for_strategy(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn strip_test_mod(src: &str) -> String {
let bytes = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut i = 0;
while i < bytes.len() {
let rel = src[i..].find("#[cfg(test)]");
if let Some(attr_pos) = rel {
let abs_pos = i + attr_pos;
if let Some(mod_offset) = skip_to_mod_tests(&src[abs_pos..]) {
let mod_abs = abs_pos + mod_offset;
if let Some(rel_brace) = find_inline_lbrace(&src[mod_abs..]) {
let open_idx = mod_abs + rel_brace;
if let Some(close_idx) = match_brace(src, open_idx) {
out.push_str(&src[i..=open_idx]);
out.push('}');
i = close_idx + 1;
continue;
}
}
}
}
let ch = match std::str::from_utf8(&bytes[i..]) {
Ok(s) => s.chars().next().unwrap_or('\u{FFFD}'),
Err(_) => '\u{FFFD}',
};
out.push(ch);
i += ch.len_utf8();
}
out
}
fn skip_to_mod_tests(src: &str) -> Option<usize> {
let bytes = src.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
if i >= bytes.len() {
return None;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
if bytes[i] == b'#' && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
let mut depth = 1;
while i < bytes.len() && depth > 0 {
if bytes[i] == b'[' {
depth += 1;
}
if bytes[i] == b']' && depth > 0 {
depth -= 1;
}
i += 1;
}
continue;
}
if src[i..].starts_with("mod") {
let after = i + 3;
if after < bytes.len() && (bytes[after] as char).is_whitespace() {
let mut j = after + 1;
loop {
while j < bytes.len() && (bytes[j] as char).is_whitespace() {
j += 1;
}
if j + 1 < bytes.len() && bytes[j] == b'/' && bytes[j + 1] == b'/' {
while j < bytes.len() && bytes[j] != b'\n' {
j += 1;
}
continue;
}
break;
}
let s = j;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j > s {
return Some(i); }
}
return None; }
return None; }
None
}
fn char_lit_starts(bytes: &[u8], i: usize) -> bool {
match bytes.get(i + 1) {
Some(b'\\') => true,
Some(_) => bytes.get(i + 2) == Some(&b'\''),
None => false,
}
}
fn skip_lifetime(bytes: &[u8], mut i: usize) -> usize {
i += 1; while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
i
}
fn find_inline_lbrace(src: &str) -> Option<usize> {
let bytes = src.as_bytes();
let mut i = 0;
let mut in_line_c = false;
let mut in_block_c = false;
let mut in_str = false;
let mut in_char = false;
while i < bytes.len() {
let b = bytes[i];
if in_line_c {
if b == b'\n' {
in_line_c = false;
}
i += 1;
continue;
}
if in_block_c {
if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_block_c = false;
i += 2;
continue;
}
i += 1;
continue;
}
if in_str {
if b == b'\\' {
i += 2;
continue;
}
if b == b'"' {
in_str = false;
}
i += 1;
continue;
}
if in_char {
if b == b'\\' {
i += 2;
continue;
}
if b == b'\'' {
in_char = false;
}
i += 1;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_line_c = true;
i += 2;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
in_block_c = true;
i += 2;
continue;
}
if b == b'"' {
in_str = true;
i += 1;
continue;
}
if b == b'\'' {
if char_lit_starts(bytes, i) {
in_char = true;
i += 1;
} else {
i = skip_lifetime(bytes, i);
}
continue;
}
if b == b'{' {
return Some(i);
}
if b == b';' {
return None;
}
i += 1;
}
None
}
fn match_brace(src: &str, open_idx: usize) -> Option<usize> {
let bytes = src.as_bytes();
if bytes[open_idx] != b'{' {
return None;
}
let mut depth: i32 = 0;
let mut i = open_idx;
let mut in_line_c = false;
let mut in_block_c = false;
let mut in_str = false;
let mut in_char = false;
while i < bytes.len() {
let b = bytes[i];
if in_line_c {
if b == b'\n' {
in_line_c = false;
}
i += 1;
continue;
}
if in_block_c {
if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_block_c = false;
i += 2;
continue;
}
i += 1;
continue;
}
if in_str {
if b == b'\\' {
i += 2;
continue;
}
if b == b'"' {
in_str = false;
}
i += 1;
continue;
}
if in_char {
if b == b'\\' {
i += 2;
continue;
}
if b == b'\'' {
in_char = false;
}
i += 1;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_line_c = true;
i += 2;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
in_block_c = true;
i += 2;
continue;
}
if b == b'"' {
in_str = true;
i += 1;
continue;
}
if b == b'\'' {
if char_lit_starts(bytes, i) {
in_char = true;
i += 1;
} else {
i = skip_lifetime(bytes, i);
}
continue;
}
if b == b'{' {
depth += 1;
}
if b == b'}' {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_collect_rs_files_finds_main() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let src_dir = Path::new(manifest_dir).join("src");
let mut files = Vec::new();
collect_rs_files(&src_dir, &mut files);
assert!(
files.iter().any(|f| f.ends_with("main.rs")),
"collect_rs_files should find src/main.rs, got: {:?}",
files
);
}
#[test]
fn test_strip_test_mod_removes_test_body() {
let src = r#"
fn prod() { let x = "sequence"; }
#[cfg(test)]
mod tests {
fn helper() { let x = "sequence".to_string(); let y = x.unwrap_or_default(); }
}
"#;
let stripped = strip_test_mod(src);
assert_eq!(
stripped.matches("\"sequence\"").count(),
1,
"strip_test_mod should remove test mod body, got: {:?}",
stripped
);
}
#[test]
fn test_match_brace_balanced() {
let src = "fn f() { let x = { let y = 1; y }; x }";
let open = src.find('{').unwrap();
let close = match_brace(src, open).unwrap();
assert_eq!(src.chars().nth(close), Some('}'));
}
#[test]
fn test_match_brace_ignores_lifetime() {
let src = "fn outer() { let mk: fn() -> &'static str; }";
let open = src.find('{').unwrap();
let close = match_brace(src, open).unwrap();
assert_eq!(src.chars().nth(close), Some('}'));
}
#[test]
fn test_strip_survives_lifetime_apostrophe() {
let src = concat!(
"#[cfg(test)]\n",
"mod tests {\n",
" fn schema() -> &'static str { \"x\" }\n",
" fn helper() { let x = something.unwrap(); }\n",
"}\n",
);
let stripped = strip_test_mod(src);
assert_eq!(
stripped.matches(".unwrap(").count(),
0,
"生命周期撇号后的 tests 体须被剥离, got: {stripped:?}"
);
}
#[test]
fn test_strip_multiple_test_mods() {
let src = concat!(
"fn prod() { let _x = 1; }\n",
"#[cfg(test)]\n",
"mod ssot_tests {\n",
" fn a() { something.unwrap(); }\n",
"}\n",
"fn prod2() { let _y = 2; }\n",
"#[cfg(test)]\n",
"mod tests {\n",
" fn b() { other.unwrap(); panic!(\"x\"); }\n",
"}\n",
);
let stripped = strip_test_mod(src);
assert_eq!(
stripped.matches(".unwrap(").count(),
0,
"两个测试模块体都须被剥离, got: {:?}",
stripped
);
assert_eq!(
stripped.matches("panic!(").count(),
0,
"非 tests 命名的测试模块体同样剥离, got: {:?}",
stripped
);
assert!(
stripped.contains("fn prod()") && stripped.contains("fn prod2()"),
"生产代码不得被误吞, got: {:?}",
stripped
);
assert_eq!(
stripped.matches("fn prod2()").count(),
1,
"模块间生产代码不得因尾部重复压入被计入两次, got: {:?}",
stripped
);
}
#[test]
fn test_char_lit_starts_discrimination() {
assert!(char_lit_starts(b"let c = '\\n';", 8));
assert!(char_lit_starts(b"let c = 'x';", 8));
assert!(!char_lit_starts(b"fn f() -> &'static str {", 12));
assert!(!char_lit_starts(b"fn f<'a>(x: &'a u8) {}", 5));
}
#[test]
fn test_squeeze_ws_catches_whitespace_bypass() {
let bypass = "let x = v.unwrap ();";
assert!(!bypass.contains(".unwrap("));
assert!(squeeze_ws(bypass).contains(".unwrap("));
assert!(squeeze_ws("let m: Hash Map<u8, u8>;").contains("HashMap"));
assert!("let x = v.unwrap();".contains(".unwrap("));
assert!(!squeeze_ws("let x = v.unwrap_or(1);").contains(".unwrap("));
}
#[test]
fn test_strip_inline_block_comments() {
let s = strip_inline_block_comments("let x = 1; /* conditional */ let y = 2;");
assert!(!s.contains("conditional"), "got: {}", s);
assert!(s.contains("let y = 2;"));
let s = strip_inline_block_comments("let s = \"a/*b\"; v.unwrap();");
assert!(s.contains("v.unwrap();"), "got: {}", s);
let s = strip_inline_block_comments("let s = r#\"/*\"#; v.unwrap();");
assert!(s.contains("v.unwrap();"), "got: {}", s);
assert_eq!(
strip_inline_block_comments("let s = \"unterminated /* x"),
"let s = \"unterminated /* x"
);
assert_eq!(
strip_inline_block_comments("fn f() { /* tail comment"),
"fn f() { /* tail comment"
);
}
#[test]
fn test_bare_word_hit() {
assert!(!bare_word_hit("this is asynchronous work", "async"));
assert!(bare_word_hit("async fn f() {}", "async"));
assert!(!bare_word_hit("asyncfn", "async"));
assert!(bare_word_hit("x.unwrap(", ".unwrap("));
assert!(!bare_word_hit("x.unwrap_or(1)", ".unwrap("));
}
}