Iter 4b: ail diff für semantischen Modul-Vergleich

ail diff <a> <b> [--json] vergleicht zwei Module strukturell per
Def-Hash. Vier Kategorien (added/removed/changed/unchanged),
alphabetisch sortiert, deterministisches JSON-Schema. Exit 1 bei
Unterschieden, Exit 0 bei Identität — skript-tauglich. Kein
Typcheck-Zwang, damit man auch kaputte Module diffen kann. Helper
def_name/def_kind in ailang-core für stabile Def-Identität.
This commit is contained in:
2026-05-07 11:11:48 +02:00
parent 93fe7237e3
commit c652b12582
4 changed files with 377 additions and 1 deletions
+235
View File
@@ -69,6 +69,20 @@ enum Cmd {
#[arg(long)]
json: bool,
},
/// Semantischer Modul-Diff per Def-Hash.
///
/// Vergleicht zwei Module rein strukturell auf Top-Level-Defs:
/// pro Name werden die Hashes der canonical Bytes verglichen. Das
/// Diff funktioniert auch, wenn ein Modul gerade nicht typecheckt —
/// nur das Schema und die JSON-Form müssen ladbar sein.
///
/// Exit-Code: 0 wenn keine Änderungen (außer `unchanged`), sonst 1.
Diff {
a: PathBuf,
b: PathBuf,
#[arg(long)]
json: bool,
},
}
fn main() -> Result<()> {
@@ -258,6 +272,20 @@ fn main() -> Result<()> {
}
}
}
Cmd::Diff { a, b, json } => {
let ma = ailang_core::load_module(&a)?;
let mb = ailang_core::load_module(&b)?;
let report = build_diff(&ma, &mb);
if json {
let v = diff_report_to_json(&report);
println!("{}", serde_json::to_string_pretty(&v)?);
} else {
print!("{}", render_diff_text(&report));
}
if !report.is_identical() {
std::process::exit(1);
}
}
Cmd::Deps { path, of, json } => {
let m = ailang_core::load_module(&path)?;
let mut entries = Vec::new();
@@ -357,3 +385,210 @@ fn walk_term(t: &ailang_core::Term, out: &mut std::collections::BTreeSet<String>
}
}
}
// --- ail diff -------------------------------------------------------------
/// Rein struktureller Modul-Diff. Top-Level-Defs werden per `name`
/// identifiziert und per BLAKE3-16-Hex der canonical Bytes verglichen.
struct DiffReport {
module_a: String,
module_b: String,
added: Vec<DiffEntry>,
removed: Vec<DiffEntry>,
changed: Vec<ChangedEntry>,
unchanged: Vec<DiffEntry>,
}
struct DiffEntry {
name: String,
hash: String,
kind: &'static str,
}
struct ChangedEntry {
name: String,
hash_a: String,
hash_b: String,
kind_a: &'static str,
kind_b: &'static str,
}
impl DiffReport {
fn is_identical(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
}
fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport {
use std::collections::BTreeMap;
let map_a: BTreeMap<&str, &ailang_core::Def> =
a.defs.iter().map(|d| (ailang_core::def_name(d), d)).collect();
let map_b: BTreeMap<&str, &ailang_core::Def> =
b.defs.iter().map(|d| (ailang_core::def_name(d), d)).collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
let mut unchanged = Vec::new();
// Removed + (un)changed: alles aus A.
for (name, def_a) in &map_a {
let hash_a = ailang_core::def_hash(def_a);
let kind_a = ailang_core::def_kind(def_a);
match map_b.get(*name) {
None => removed.push(DiffEntry {
name: (*name).to_string(),
hash: hash_a,
kind: kind_a,
}),
Some(def_b) => {
let hash_b = ailang_core::def_hash(def_b);
let kind_b = ailang_core::def_kind(def_b);
if hash_a == hash_b {
unchanged.push(DiffEntry {
name: (*name).to_string(),
hash: hash_a,
kind: kind_a,
});
} else {
changed.push(ChangedEntry {
name: (*name).to_string(),
hash_a,
hash_b,
kind_a,
kind_b,
});
}
}
}
}
// Added: was nur in B vorkommt.
for (name, def_b) in &map_b {
if !map_a.contains_key(*name) {
added.push(DiffEntry {
name: (*name).to_string(),
hash: ailang_core::def_hash(def_b),
kind: ailang_core::def_kind(def_b),
});
}
}
// BTreeMap-Iteration ist bereits alphabetisch — keine extra-Sortierung
// nötig, aber explizit absichern, falls die Reihenfolge der Quelle
// jemals umgestellt wird.
added.sort_by(|x, y| x.name.cmp(&y.name));
removed.sort_by(|x, y| x.name.cmp(&y.name));
changed.sort_by(|x, y| x.name.cmp(&y.name));
unchanged.sort_by(|x, y| x.name.cmp(&y.name));
DiffReport {
module_a: a.name.clone(),
module_b: b.name.clone(),
added,
removed,
changed,
unchanged,
}
}
fn diff_report_to_json(r: &DiffReport) -> serde_json::Value {
let entry = |e: &DiffEntry| {
serde_json::json!({
"name": e.name,
"hash": e.hash,
"kind": e.kind,
})
};
let changed = |c: &ChangedEntry| {
serde_json::json!({
"name": c.name,
"hash_a": c.hash_a,
"hash_b": c.hash_b,
"kind_a": c.kind_a,
"kind_b": c.kind_b,
})
};
serde_json::json!({
"module_a": r.module_a,
"module_b": r.module_b,
"added": r.added.iter().map(entry).collect::<Vec<_>>(),
"removed": r.removed.iter().map(entry).collect::<Vec<_>>(),
"changed": r.changed.iter().map(changed).collect::<Vec<_>>(),
"unchanged": r.unchanged.iter().map(entry).collect::<Vec<_>>(),
})
}
fn render_diff_text(r: &DiffReport) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "diff: {} -> {}", r.module_a, r.module_b);
if r.is_identical() && r.unchanged.is_empty() {
let _ = writeln!(out, "no changes");
return out;
}
// Einheitliche Spaltenbreite für Namens-/Kind-Spalte, damit Hashes
// visuell aligned sind. Längster Name bestimmt die Breite.
let name_width = r
.added
.iter()
.map(|e| e.name.len())
.chain(r.removed.iter().map(|e| e.name.len()))
.chain(r.changed.iter().map(|e| e.name.len()))
.chain(r.unchanged.iter().map(|e| e.name.len()))
.max()
.unwrap_or(0);
for e in &r.added {
let _ = writeln!(
out,
"+ {:<width$} ({}) {}",
e.name,
e.kind,
e.hash,
width = name_width
);
}
for e in &r.removed {
let _ = writeln!(
out,
"- {:<width$} ({}) {}",
e.name,
e.kind,
e.hash,
width = name_width
);
}
for c in &r.changed {
// Wenn sich der Kind geändert hat (z. B. const → fn), beide zeigen.
let kind = if c.kind_a == c.kind_b {
c.kind_a.to_string()
} else {
format!("{} -> {}", c.kind_a, c.kind_b)
};
let _ = writeln!(
out,
"~ {:<width$} ({}) {} -> {}",
c.name,
kind,
c.hash_a,
c.hash_b,
width = name_width
);
}
for e in &r.unchanged {
let _ = writeln!(
out,
" {:<width$} ({}) {} (unchanged)",
e.name,
e.kind,
e.hash,
width = name_width
);
}
out
}
+123
View File
@@ -64,6 +64,129 @@ fn list_sum_via_match() {
assert_eq!(stdout.trim(), "42");
}
/// Schützt `ail diff`: ein modifizierter Body ändert den Hash von `sum`,
/// während `main` unverändert bleibt. Erwartet Exit-Code 1, `changed`
/// enthält genau `sum`, `unchanged` enthält `main`, `added`/`removed` leer.
#[test]
fn diff_detects_changed_def() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src_a = workspace.join("examples").join("sum.ail.json");
// Variante: lade sum.ail.json, mutiere den `then`-Zweig (statt 0 nun 1)
// in der `sum`-Definition. `main` bleibt bitidentisch.
let raw = std::fs::read(&src_a).expect("read sum.ail.json");
let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json");
{
let defs = module
.get_mut("defs")
.and_then(|d| d.as_array_mut())
.expect("defs array");
for def in defs.iter_mut() {
if def.get("name").and_then(|n| n.as_str()) == Some("sum") {
// Ersetze den then-Zweig literal 0 → literal 1.
let new_then = serde_json::json!({
"t": "lit",
"lit": { "kind": "int", "value": 1 }
});
def["body"]["then"] = new_then;
}
}
}
let tmp = std::env::temp_dir().join(format!(
"ailang_diff_changed_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let path_b = tmp.join("sum_v2.ail.json");
std::fs::write(&path_b, serde_json::to_vec_pretty(&module).unwrap())
.expect("write sum_v2.ail.json");
let output = Command::new(ail_bin())
.args([
"diff",
src_a.to_str().unwrap(),
path_b.to_str().unwrap(),
"--json",
])
.output()
.expect("ail diff failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
1,
"expected exit code 1 for differing modules; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let added = v["added"].as_array().expect("added array");
let removed = v["removed"].as_array().expect("removed array");
let changed = v["changed"].as_array().expect("changed array");
let unchanged = v["unchanged"].as_array().expect("unchanged array");
assert!(added.is_empty(), "expected added empty: {added:?}");
assert!(removed.is_empty(), "expected removed empty: {removed:?}");
assert_eq!(changed.len(), 1, "expected one changed entry: {changed:?}");
assert_eq!(
changed[0].get("name").and_then(|n| n.as_str()),
Some("sum")
);
assert_ne!(
changed[0].get("hash_a").and_then(|n| n.as_str()),
changed[0].get("hash_b").and_then(|n| n.as_str()),
"hash_a and hash_b must differ for a changed def"
);
assert!(
unchanged.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("main")),
"expected `main` in unchanged: {unchanged:?}"
);
}
/// Diff eines Moduls mit sich selbst: Exit 0, alle Listen außer `unchanged` leer.
#[test]
fn diff_no_changes_exit_zero() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("sum.ail.json");
let output = Command::new(ail_bin())
.args([
"diff",
src.to_str().unwrap(),
src.to_str().unwrap(),
"--json",
])
.output()
.expect("ail diff failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
0,
"expected exit code 0 for identical modules; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
assert!(v["added"].as_array().unwrap().is_empty());
assert!(v["removed"].as_array().unwrap().is_empty());
assert!(v["changed"].as_array().unwrap().is_empty());
assert!(
!v["unchanged"].as_array().unwrap().is_empty(),
"self-diff should report unchanged defs"
);
}
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
+16
View File
@@ -36,6 +36,22 @@ impl Def {
}
}
/// Externer Helper: Name einer Definition (für Tools wie `ail diff`,
/// die den Def-Knoten entkoppelt vom Methoden-Aufruf konsumieren).
pub fn def_name(def: &Def) -> &str {
def.name()
}
/// Externer Helper: Diskriminator-Tag einer Definition (`fn`, `const`, `type`).
/// Identisch mit dem `kind`-Feld in der JSON-Repräsentation.
pub fn def_kind(def: &Def) -> &'static str {
match def {
Def::Fn(_) => "fn",
Def::Const(_) => "const",
Def::Type(_) => "type",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypeDef {
pub name: String,
+3 -1
View File
@@ -8,7 +8,9 @@ pub mod canonical;
pub mod hash;
pub mod pretty;
pub use ast::{ConstDef, Def, FnDef, Import, Literal, Module, Term, Type};
pub use ast::{
def_kind, def_name, ConstDef, Def, FnDef, Import, Literal, Module, Term, Type,
};
pub use hash::def_hash;
#[derive(Debug, thiserror::Error)]