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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user