use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::spec::backend::FEATURE_BACKEND;
use crate::sdd::spec::backend::feature_backend::{self};
use crate::sdd::spec::validation::{ValidationIssue, ValidationLevel};
use anyhow::Context;
use std::collections::BTreeMap;
use std::path::Path;
type Hashes = BTreeMap<String, usize>;
pub fn check(root: &Path, base_sha: &str, rules_edit_acked: bool) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
let specs_prefix = format!("{LLMANSPEC_DIR_NAME}/specs/");
let changed = match changed_feature_files(root, base_sha, &specs_prefix) {
Ok(files) => files,
Err(err) => {
issues.push(ValidationIssue {
level: ValidationLevel::Warning,
path: "lock-gate".to_string(),
message: format!("could not diff locked rules against base: {err}"),
});
return issues;
}
};
if changed.is_empty() {
return issues;
}
let mut violations: Vec<String> = Vec::new();
let mut touched_locked = false;
for rel in &changed {
let before = hashes_at(root, base_sha, rel).unwrap_or_default();
let after = worktree_hashes(root, rel).unwrap_or_default();
if before.is_empty() {
continue;
}
touched_locked = true;
report_diff(rel, &before, &after, &mut violations);
}
if touched_locked && !violations.is_empty() {
if rules_edit_acked {
issues.push(ValidationIssue {
level: ValidationLevel::Info,
path: "lock-gate".to_string(),
message: format!(
"locked @human scenarios modified with rules_edit_acked: {}",
violations.join("; ")
),
});
} else {
issues.push(ValidationIssue {
level: ValidationLevel::Error,
path: "lock-gate".to_string(),
message: format!(
"locked @human scenarios were modified without human ack \
(add `rules_edit_acked: true` to proposal frontmatter after review): {}",
violations.join("; ")
),
});
}
}
issues
}
fn report_diff(rel: &str, before: &Hashes, after: &Hashes, out: &mut Vec<String>) {
let mut keys: std::collections::BTreeSet<&String> = before.keys().collect();
keys.extend(after.keys());
for hash in keys {
let b = before.get(hash).copied().unwrap_or(0);
let a = after.get(hash).copied().unwrap_or(0);
if b == 0 || b == a {
continue;
}
let kind = if b == 0 {
"added"
} else if a == 0 {
"removed"
} else {
"modified"
};
let short = &hash[..hash.len().min(12)];
out.push(format!("{rel}: {kind} rule ({short})"));
}
}
fn changed_feature_files(root: &Path, base_sha: &str, prefix: &str) -> anyhow::Result<Vec<String>> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", base_sha, "HEAD", "--", prefix])
.current_dir(root)
.output()
.context("git diff --name-only")?;
ensure_success(&output)?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|l| l.ends_with(".feature"))
.map(str::to_string)
.collect())
}
fn hashes_at(root: &Path, base_sha: &str, rel: &str) -> Option<Hashes> {
let output = std::process::Command::new("git")
.args(["show", &format!("{base_sha}:{rel}")])
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return Some(BTreeMap::new()); }
let content = String::from_utf8(output.stdout).ok()?;
Some(hashes_from_content(&content))
}
fn worktree_hashes(root: &Path, rel: &str) -> Option<Hashes> {
let content = fs_read(root.join(rel)).ok()?;
Some(hashes_from_content(&content))
}
fn fs_read(path: std::path::PathBuf) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
fn hashes_from_content(content: &str) -> Hashes {
let mut hashes: Hashes = BTreeMap::new();
if let Ok(parsed) = FEATURE_BACKEND.parse_content(content, "lock-gate") {
for sc in parsed
.scenarios
.iter()
.filter(|sc| sc.tier.map(|t| t.is_locked()).unwrap_or(false))
{
*hashes.entry(feature_backend::lock_hash(sc)).or_insert(0) += 1;
}
}
hashes
}
fn ensure_success(output: &std::process::Output) -> anyhow::Result<()> {
if !output.status.success() {
anyhow::bail!(
"git failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
pub fn rules_edit_acked_for(root: &Path, change_name: &str) -> bool {
let proposal = root
.join(LLMANSPEC_DIR_NAME)
.join("changes")
.join(change_name)
.join("proposal.md");
let Ok(content) = std::fs::read_to_string(proposal) else {
return false;
};
let (yaml, _body) = crate::sdd::spec::frontmatter::split_frontmatter(&content);
let Some(yaml) = yaml else {
return false;
};
serde_yaml::from_str::<serde_yaml::Value>(&yaml)
.ok()
.map(|v| crate::sdd::spec::validation::parse_yaml_optional_bool(&v, "rules_edit_acked"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
const FEATURE_V1: &str = "\
# language: en\n# capability: demo\n# purpose: p\n# scope: src/\n\nFeature: demo\n\n @req:r1 @human\n Scenario: R1\n System MUST do X.\n";
fn feature_v2_modified() -> String {
FEATURE_V1.replace("do X.", "do Y.")
}
const FEATURE_V2_ADDED: &str = concat!(
"# language: en\n# capability: demo\n# purpose: p\n# scope: src/\n\nFeature: demo\n\n",
" @req:r1 @human\n Scenario: R1\n System MUST do X.\n\n",
" @req:r2 @human\n Scenario: R2\n System MUST do Z.\n"
);
fn git(root: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@x"])
.arg(args[0])
.args(&args[1..])
.current_dir(root)
.output()
.expect("git");
assert!(out.status.success(), "git {:?} failed", args);
}
#[test]
fn modify_and_delete_require_ack_but_adding_does_not() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let dir = root.join(LLMANSPEC_DIR_NAME).join("specs").join("demo");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("demo.feature"), FEATURE_V1).unwrap();
git(root, &["init", "-q"]);
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let base = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("demo.feature"), feature_v2_modified()).unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "v2"]);
let issues = check(root, &base, false);
assert!(
issues.iter().any(
|i| i.level == ValidationLevel::Error && i.message.contains("rules_edit_acked")
),
"{issues:?}"
);
let issues = check(root, &base, true);
assert!(issues.iter().all(|i| i.level != ValidationLevel::Error));
std::fs::write(dir.join("demo.feature"), FEATURE_V2_ADDED).unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "v3"]);
let issues = check(root, &base, false);
assert!(
issues.iter().all(|i| i.level != ValidationLevel::Error),
"adding rules must not require ack: {issues:?}"
);
std::fs::remove_file(dir.join("demo.feature")).unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "v4"]);
let issues = check(root, &base, false);
assert!(
issues
.iter()
.any(|i| i.level == ValidationLevel::Error && i.message.contains("removed")),
"{issues:?}"
);
}
#[test]
fn rules_edit_acked_accepts_yaml_string_bool() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let change_dir = root.join(LLMANSPEC_DIR_NAME).join("changes").join("c-ack");
std::fs::create_dir_all(&change_dir).unwrap();
std::fs::write(
change_dir.join("proposal.md"),
"---\ndepends_on: []\nrules_edit_acked: \"true\"\n---\n## Why\nx\n",
)
.unwrap();
assert!(
rules_edit_acked_for(root, "c-ack"),
"string \"true\" must be honored like a YAML bool (H2)"
);
}
}