use kglite::api::{DirGraph, GraphRead, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
const CORPORA: &[&str] = &[
"py_basic",
"py_inheritance",
"rust_xfile",
"ts_callback",
"cross_ts_py",
"dup_minified_assets",
"agc_basic",
];
const BUILDS_PER_CORPUS: usize = 3;
fn corpus_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("tests")
.join("corpus")
}
fn canon(v: &Value) -> String {
format!("{v:?}")
}
fn node_type_counts(g: &DirGraph) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for idx in g.graph.node_indices() {
if let Some(n) = g.graph.node_weight(idx) {
*m.entry(n.node_type_str(&g.interner).to_string())
.or_insert(0) += 1;
}
}
m
}
fn edge_type_counts(g: &DirGraph) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for e in g.graph.edge_indices() {
if let Some(edge) = g.graph.edge_weight(e) {
*m.entry(edge.connection_type_str(&g.interner).to_string())
.or_insert(0) += 1;
}
}
m
}
fn node_identities(g: &DirGraph) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = g
.graph
.node_indices()
.filter_map(|i| g.graph.node_weight(i))
.map(|n| (n.node_type_str(&g.interner).to_string(), n.id().to_string()))
.collect();
v.sort();
v
}
fn node_props(g: &DirGraph) -> Vec<(String, String, BTreeMap<String, String>)> {
let mut v: Vec<(String, String, BTreeMap<String, String>)> = g
.graph
.node_indices()
.filter_map(|i| g.graph.node_weight(i))
.map(|n| {
let props: BTreeMap<String, String> = n
.properties_cloned(&g.interner)
.iter()
.map(|(k, val)| (k.clone(), canon(val)))
.collect();
(
n.node_type_str(&g.interner).to_string(),
n.id().to_string(),
props,
)
})
.collect();
v.sort();
v
}
fn edge_props(g: &DirGraph) -> Vec<(String, String, String, BTreeMap<String, String>)> {
let mut v: Vec<(String, String, String, BTreeMap<String, String>)> = g
.graph
.edge_indices()
.filter_map(|e| {
let edge = g.graph.edge_weight(e)?;
let (s, t) = g.graph.edge_endpoints(e)?;
let sn = g.graph.node_weight(s)?;
let tn = g.graph.node_weight(t)?;
let props: BTreeMap<String, String> = edge
.properties_cloned(&g.interner)
.iter()
.map(|(k, val)| (k.clone(), canon(val)))
.collect();
Some((
edge.connection_type_str(&g.interner).to_string(),
sn.id().to_string(),
tn.id().to_string(),
props,
))
})
.collect();
v.sort();
v
}
fn canonical_graph_string(g: &DirGraph) -> String {
let mut s = String::new();
s.push_str("## node_type_counts\n");
for (ty, n) in node_type_counts(g) {
s.push_str(&format!("{ty}\t{n}\n"));
}
s.push_str("## edge_type_counts\n");
for (ty, n) in edge_type_counts(g) {
s.push_str(&format!("{ty}\t{n}\n"));
}
s.push_str("## node_identities\n");
for (ty, id) in node_identities(g) {
s.push_str(&format!("{ty}\t{id}\n"));
}
s.push_str("## node_props\n");
for (ty, id, props) in node_props(g) {
s.push_str(&format!("{ty}\t{id}\n"));
for (k, v) in props {
s.push_str(&format!("\t{k}={v}\n"));
}
}
s.push_str("## edge_props\n");
for (conn, src, tgt, props) in edge_props(g) {
s.push_str(&format!("{conn}\t{src}\t{tgt}\n"));
for (k, v) in props {
s.push_str(&format!("\t{k}={v}\n"));
}
}
s
}
fn graph_digest(g: &DirGraph) -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_graph_string(g).as_bytes());
let out = hasher.finalize();
out.iter().map(|b| format!("{b:02x}")).collect()
}
fn goldens_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("goldens")
}
fn read_golden(corpus: &str) -> String {
let path = goldens_dir().join(format!("{corpus}.sha256"));
std::fs::read_to_string(&path)
.unwrap_or_else(|e| {
panic!(
"missing golden {} — capture with `cargo test -p codingest --test parity -- --ignored capture_goldens`: {e}",
path.display()
)
})
.trim()
.to_string()
}
fn assert_graphs_equiv(label: &str, left: &DirGraph, right: &DirGraph) {
let (a, b) = (node_type_counts(left), node_type_counts(right));
assert_eq!(
a, b,
"[{label}] node-type counts differ\nleft={a:?}\nright={b:?}"
);
let (a, b) = (edge_type_counts(left), edge_type_counts(right));
assert_eq!(
a, b,
"[{label}] edge-type counts differ\nleft={a:?}\nright={b:?}"
);
let (a, b) = (node_identities(left), node_identities(right));
if a != b {
let only_in: Vec<_> = a.iter().filter(|x| !b.contains(x)).take(10).collect();
let only_st: Vec<_> = b.iter().filter(|x| !a.contains(x)).take(10).collect();
panic!(
"[{label}] node identity sets differ\nonly left (<=10): {only_in:?}\nonly right (<=10): {only_st:?}"
);
}
let (a, b) = (node_props(left), node_props(right));
if a != b {
for (x, y) in a.iter().zip(b.iter()) {
if x != y {
panic!(
"[{label}] node properties differ\nfirst mismatch:\n left=({}, {}) {:?}\n right=({}, {}) {:?}",
x.0, x.1, x.2, y.0, y.1, y.2
);
}
}
panic!(
"[{label}] node property lists differ in length: left={} right={}",
a.len(),
b.len()
);
}
let (a, b) = (edge_props(left), edge_props(right));
if a != b {
for (x, y) in a.iter().zip(b.iter()) {
if x != y {
panic!(
"[{label}] edge properties differ\nfirst mismatch:\n left=({}, {}->{}) {:?}\n right=({}, {}->{}) {:?}",
x.0, x.1, x.2, x.3, y.0, y.1, y.2, y.3
);
}
}
panic!(
"[{label}] edge property lists differ in length: left={} right={}",
a.len(),
b.len()
);
}
}
#[test]
fn golden_parity() {
let root = corpus_root();
for name in CORPORA {
let dir = root.join(name);
assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());
let build = || {
codingest::builder::run_with_options(&dir, false, true, None, None, true)
.unwrap_or_else(|e| panic!("[{name}] codingest build failed: {e}"))
};
let first = build();
let got = graph_digest(&first);
for run in 2..=BUILDS_PER_CORPUS {
let again = build();
let again_digest = graph_digest(&again);
if again_digest != got {
assert_graphs_equiv(
&format!("{name} nondeterminism run 1 vs {run}"),
&first,
&again,
);
panic!(
"[{name}] NONDETERMINISM: build 1 and build {run} of the same corpus \
produced different digests ({got} != {again_digest}) but compared equal \
dimension-by-dimension — widen the canonical rendering."
);
}
}
let want = read_golden(name);
assert_eq!(
got, want,
"[{name}] golden digest mismatch\n golden (frozen authority) = {want}\n codingest build = {got}\n\
All {BUILDS_PER_CORPUS} builds agreed, so this is a builder BEHAVIOUR change, not nondeterminism.\n\
If it is deliberate, regenerate with\n\
`cargo test -p codingest --test parity -- --ignored capture_goldens`."
);
}
}
#[test]
#[ignore = "regeneration path; run explicitly with --ignored capture_goldens"]
fn capture_goldens() {
let root = corpus_root();
let out_dir = goldens_dir();
std::fs::create_dir_all(&out_dir).expect("create goldens dir");
for name in CORPORA {
let dir = root.join(name);
assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());
let g = codingest::builder::run_with_options(&dir, false, true, None, None, true)
.unwrap_or_else(|e| panic!("[{name}] codingest build failed: {e}"));
let digest = graph_digest(&g);
let path = out_dir.join(format!("{name}.sha256"));
std::fs::write(&path, format!("{digest}\n")).expect("write golden");
eprintln!("captured {name} -> {digest}");
}
}
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn git_commit(dir: &Path, msg: &str) -> String {
git(dir, &["add", "-A"]);
git(
dir,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
msg,
],
);
git(dir, &["rev-parse", "HEAD"])
}
fn build_two_commit_repo(repo: &Path) -> Vec<String> {
git(repo, &["init", "-q"]);
std::fs::create_dir_all(repo.join("pkg")).unwrap();
std::fs::write(
repo.join("pkg/mod_a.py"),
"def foo(a):\n return a + 1\n\n\ndef gone():\n return 0\n",
)
.unwrap();
std::fs::write(
repo.join("pkg/mod_b.py"),
"from pkg.mod_a import foo\n\n\ndef driver(x):\n return foo(x)\n",
)
.unwrap();
let s1 = git_commit(repo, "rev1");
std::fs::write(
repo.join("pkg/mod_a.py"),
"def foo(a, b):\n return a + b\n\n\ndef bar(y):\n return y * 2\n",
)
.unwrap();
std::fs::write(
repo.join("pkg/mod_b.py"),
"from pkg.mod_a import foo, bar\n\n\ndef driver(x):\n return foo(x, 1) + bar(x)\n",
)
.unwrap();
let s2 = git_commit(repo, "rev2");
vec![s1, s2]
}
fn assert_has_stamped_revs(g: &DirGraph) {
let has_revs = g
.graph
.node_indices()
.filter_map(|i| g.graph.node_weight(i))
.any(|n| matches!(n.get_property_value("revs"), Some(Value::List(_))));
assert!(
has_revs,
"expected stamped `revs` list properties on rev-merge graph"
);
}
#[test]
fn rev_self_consistency() {
if !git_available() {
eprintln!("SKIP rev_self_consistency: git not available in test environment");
return;
}
let tmp = tempfile::Builder::new()
.prefix("codingest-rev-selfconsistency-")
.tempdir()
.unwrap();
let repo = tmp.path();
let revs = build_two_commit_repo(repo);
let build = || {
codingest::rev::build_code_tree_revs(repo, &revs, Some(repo), false, true, None, None, true)
.expect("codingest build_code_tree_revs")
};
let first = build();
let second = build();
assert_has_stamped_revs(&first);
assert_graphs_equiv("rev-self-consistency", &first, &second);
}