Skip to content

Diff two revisions

Available in: Python · Node · CLI · Browser

When: a resubmission lands and you need to know what actually changed between Rev A and Rev B — not a line diff, but a KEY-aware, type-aware delta.

# what this shows: laterite.diff(a, b) — a KEY-aware, type-aware revision diff between two AGS4 texts.
from pathlib import Path

import laterite

# Two revisions of the same submission, differing in one PROJ cell (PROJ_NAME).
baseline = Path("examples/sample_site.ags").read_text()
revision = baseline.replace(
    "laterite demo site (synthetic starter - replace me)",
    "laterite demo site (Rev B)",
)
assert revision != baseline

# diff() returns a RevisionDelta dict: per-group row/heading deltas + counts.
# Rows are matched by the group's dictionary KEY headings, and cells are compared
# through the *typed* value, so only a genuine quantity change registers.
delta = laterite.diff(baseline, revision)

# Pull out the PROJ group and its single changed row.
proj = next(g for g in delta["groups"] if g["code"] == "PROJ")
changed = [row for row in proj["rows"] if row["kind"] == "changed"]

print("totals:", delta["total_added"], delta["total_removed"], delta["total_changed"])
print("PROJ key headings:", proj["key_headings"])
print("changed row key:", changed[0]["key"])
print("changed cell:", changed[0]["cells"][0])

# A 'changed' PROJ row, keyed on PROJ_ID, carrying a heading/type/a/b cell.
assert delta["total_changed"] == 1
assert proj["key_headings"] == ["PROJ_ID"]
assert proj["keyed"] is True
row = changed[0]
assert row["kind"] == "changed"
assert row["key"] == ["LAT-DEMO"]  # the PROJ_ID value
cell = row["cells"][0]
assert cell["heading"] == "PROJ_NAME"
assert cell["type"] == "X"
assert cell["a"] == "laterite demo site (synthetic starter - replace me)"
assert cell["b"] == "laterite demo site (Rev B)"
totals: 0 0 1
PROJ key headings: ['PROJ_ID']
changed row key: ['LAT-DEMO']
changed cell: {'heading': 'PROJ_NAME', 'type': 'X', 'a': 'laterite demo site (synthetic starter - replace me)', 'b': 'laterite demo site (Rev B)'}

laterite.diff(a, b) compares two AGS4 texts and returns a RevisionDelta — a per-group breakdown plus the total_added / total_removed / total_changed counts. It is not a text diff: rows are matched on each group's dictionary KEY headings (here PROJ_ID), so a row that moved or was reordered still lines up with its counterpart. The single edit above registers as one changed row keyed on ["LAT-DEMO"], carrying a cell that names the heading, its AGS type, and the a/b values.

Because cells are compared through the born-typed value, only a genuine quantity change registers — 1.50 vs 1.5 on a 2DP column is the same number and produces no delta, where a naive line diff would flag it.

Walk the structure to drive a review: each group in delta["groups"] carries code, key_headings, keyed (whether the group has KEYs to match on), and a rows list. Each row has a kind (added / removed / changed), its key, and — for a changed row — a cells list of {heading, type, a, b}:

for group in delta["groups"]:
    for row in group["rows"]:
        if row["kind"] == "changed":
            for cell in row["cells"]:
                print(group["code"], row["key"], cell["heading"], cell["a"], "→", cell["b"])

Gotcha: matching is only as precise as the KEYs. A group with no KEY headings (keyed is False) falls back to positional comparison, so a genuine row insertion there can read as a cascade of changed rows rather than one added. Check keyed before trusting row identity on KEY-less groups.

// what this shows: diff(a, b) — a KEY-aware, type-aware revision diff between two AGS4 revisions.
import { diff } from "laterite";
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";

// Two revisions of the same submission, differing in one PROJ cell (PROJ_NAME).
// A bare string is a PATH in Node, so pass bytes for an in-memory revision.
const baseline = readFileSync("examples/sample_site.ags");
const revision = Buffer.from(
  baseline
    .toString("utf8")
    .replace(
      "laterite demo site (synthetic starter - replace me)",
      "laterite demo site (Rev B)",
    ),
);
assert.ok(!revision.equals(baseline));

// diff() returns a RevisionDelta: per-group row/heading deltas + counts. Rows are
// matched by the group's dictionary KEY headings, and cells are compared through
// the typed value — so only a genuine quantity change registers. The shape is
// byte-identical to Python / wasm / `lat-check --diff`.
const delta = diff(baseline, revision);

const proj = delta.groups.find((g) => g.code === "PROJ");
const changed = proj.rows.filter((r) => r.kind === "changed");

console.log(
  "totals:",
  delta.total_added,
  delta.total_removed,
  delta.total_changed,
);
console.log("PROJ key headings:", proj.key_headings);
console.log("changed row key:", changed[0].key);
console.log("changed cell:", changed[0].cells[0]);

assert.equal(delta.total_changed, 1);
assert.deepEqual(proj.key_headings, ["PROJ_ID"]);
assert.equal(proj.keyed, true);
assert.deepEqual(changed[0].key, ["LAT-DEMO"]); // the PROJ_ID value
const cell = changed[0].cells[0];
assert.equal(cell.heading, "PROJ_NAME");
assert.equal(cell.type, "X");
assert.equal(cell.b, "laterite demo site (Rev B)");
totals: 0 0 1
PROJ key headings: [ 'PROJ_ID' ]
changed row key: [ 'LAT-DEMO' ]
changed cell: {
  heading: 'PROJ_NAME',
  type: 'X',
  a: 'laterite demo site (synthetic starter - replace me)',
  b: 'laterite demo site (Rev B)'
}

diff(a, b) runs the same laterite-ags4-diff engine and returns the byte-identical RevisionDelta — the snake_case field names (total_changed, key_headings, keyed) match Python one-for-one, so the same walk drives a review in either language. One surface note: a bare string is a path in Node (diff("a.ags", "b.ags") compares two files), so pass a Buffer / Uint8Array — as above — when the revision only exists in memory. It's synchronous; no DuckDB peer needed.

lat diff <file> <other> compares two revisions on disk and prints the per-group delta, exiting 0:

lat diff examples/sample_site.ags examples/rev.ags
examples/sample_site.ags → examples/rev.ags
  PROJ   +0 -0 ~1
  total: +0 added · −0 removed · ~1 changed

The CLI reports the summary shape — per group, +added −removed ~changed — off the same KEY-aware, type-aware engine, so the counts agree with every other surface. When you need the cell-level a/b detail shown in the Python/Node tabs, reach for diff() in a script.

Open the web app's revision-diff tool and drop in Rev A and Rev B. The same KEY-aware, type-aware engine runs compiled to WebAssembly, entirely client-side — you get the per-group added/removed/changed breakdown with each changed cell's a/b values, and neither file leaves your machine.

Every door runs the same diff leaf and reports the same delta, so a 1.50 vs 1.5 non-change stays a non-change everywhere. Reach for lat diff in a resubmission gate, diff() in a pipeline when you need the cell-level detail, or the browser tool for a quick visual compare.

See also: Certify a file · Born typed