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)]
|
#[arg(long)]
|
||||||
json: bool,
|
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<()> {
|
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 } => {
|
Cmd::Deps { path, of, json } => {
|
||||||
let m = ailang_core::load_module(&path)?;
|
let m = ailang_core::load_module(&path)?;
|
||||||
let mut entries = Vec::new();
|
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");
|
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.
|
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
|
||||||
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
|
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
|
||||||
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TypeDef {
|
pub struct TypeDef {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ pub mod canonical;
|
|||||||
pub mod hash;
|
pub mod hash;
|
||||||
pub mod pretty;
|
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;
|
pub use hash::def_hash;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|||||||
Reference in New Issue
Block a user