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
}