feat(standalone): sexprDiff structural comparator + vitest (0003/0004 §B)

Add the shared, uuid-keyed structural s-expr comparator (README §B) used by both
the live drift check (0003) and the round-trip tests (0004), plus a vitest setup.

- wasm/collab/sexpr-diff.ts: zero-dependency module (importable from standalone
  app code and the Playwright specs). Tokenizes s-expr text into nested lists,
  indexes item nodes by their direct (uuid "X") child, compares item sets order-
  insensitively as multisets of normalized children, emitting
  { equal, added, removed, changed } with per-property a/b. ignoreTokens drops
  volatile tokens. False-positive-free only for same-serializer inputs.
- wasm/collab/sexpr-diff.test.ts: 15 cases (parse, equality, changes, ignoreTokens).
- vitest config + test scripts (node env, isolated from the app build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-10 18:06:06 +02:00
commit f29ceb4964
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 706 additions and 2 deletions

View file

@ -8,7 +8,9 @@
"link-wasm": "node scripts/link-wasm.mjs",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hocuspocus/provider": "^4.1.1",
@ -38,6 +40,7 @@
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^3.0.0"
}
}

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { parseSexpr, sexprDiff } from "./sexpr-diff";
// A tiny item helper: a list node `(type (uuid "ID") <props…>)` as text.
const item = (type: string, id: string, ...props: string[]) =>
`(${type} (uuid "${id}") ${props.join(" ")})`;
describe("parseSexpr", () => {
it("parses nested lists and atoms", () => {
expect(parseSexpr("(a b (c 1))")).toEqual([["a", "b", ["c", "1"]]]);
});
it("keeps quoted strings (with quotes) as single atoms, spaces preserved", () => {
expect(parseSexpr('(text "hello world")')).toEqual([["text", '"hello world"']]);
});
it("handles escaped quotes inside strings", () => {
expect(parseSexpr('(t "a\\"b")')).toEqual([["t", '"a\\"b"']]);
});
it("throws on unbalanced parens", () => {
expect(() => parseSexpr("(a (b)")).toThrow(/unbalanced/);
});
it("throws on an unterminated string", () => {
expect(() => parseSexpr('(a "oops)')).toThrow(/unterminated/);
});
});
describe("sexprDiff — equality", () => {
it("identical text is equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
expect(sexprDiff(a, a).equal).toBe(true);
});
it("reordered items are equal (keyed by uuid)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u2", "(x 2)")} ${item("seg", "u1", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("reordered properties within an item are equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)", "(layer F)")})`;
const b = `(root ${item("seg", "u1", "(layer F)", "(y 2)", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("whitespace differences are irrelevant", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root\n (seg (uuid "u1")\n (x 1) )\n)`;
expect(sexprDiff(a, b).equal).toBe(true);
});
});
describe("sexprDiff — changes", () => {
it("catches a single changed value with uuid + path + a/b", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)", "(y 9)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed).toHaveLength(1);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "y", a: "(2 y)", b: "(9 y)" });
});
it("distinguishes a quoted string from the same bare token", () => {
const a = `(root ${item("t", "u1", '(val "5")')})`;
const b = `(root ${item("t", "u1", "(val 5)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "val" });
});
it("detects an added item (present only in B)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.added).toEqual(["u2"]);
expect(r.removed).toEqual([]);
});
it("detects a removed item (present only in A)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.removed).toEqual(["u2"]);
expect(r.added).toEqual([]);
});
it("compares nested blob items by their own uuids", () => {
const a = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 1)")}))`;
const b = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 2)")}))`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
// The nested pad's value change surfaces under its own uuid…
expect(r.changed.some((c) => c.uuid === "p1" && c.path === "size")).toBe(true);
// …and the parent footprint differs too (it contains the pad subtree).
expect(r.changed.some((c) => c.uuid === "f1")).toBe(true);
});
});
describe("sexprDiff — ignoreTokens", () => {
it("ignores a volatile token so equal-but-for-version compares equal", () => {
const a = `(root ${item("seg", "u1", '(generator_version "8.0")', "(x 1)")})`;
const b = `(root ${item("seg", "u1", '(generator_version "9.9")', "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(false); // by default the version diff shows
expect(sexprDiff(a, b, { ignoreTokens: ["generator_version"] }).equal).toBe(true);
});
});

View file

@ -0,0 +1,252 @@
/**
* `sexprDiff` — a minimal, uuid-keyed STRUCTURAL comparator for KiCad s-expr text
* (README §B; shared by the live drift check 0003 and the round-trip tests 0004).
*
* It is deliberately NOT a domain parser — a full KiCad parse could itself be a
* source of false positives. It does only four things:
*
* 1. Tokenize the s-expr text into a tree of nested lists + atoms (balanced
* parens, quoted strings with escapes, bare atoms). No KiCad semantics.
* 2. Index "item" nodes by uuid: any list node with a direct `(uuid "X")` child
* is an item keyed by `X`. Nested items are collected too.
* 3. Compare order-insensitively, strictly on values: the two item SETS must
* have the same uuid keys, and for each shared uuid the two nodes must be
* equal as multisets of normalized children (reordering of sibling
* properties / items is allowed; any added / removed / value-changed token
* is a diff).
* 4. Emit `{ equal, added, removed, changed }` for readable failures.
*
* IMPORTANT — compare like-for-like. This is only false-positive-free when both
* inputs are produced by the SAME tool serializer (e.g. 0004 saves the live model
* AND a model rebuilt from the Y.Doc, both through the tool's own writer). Feeding
* a saved file against decomposed-scalar Y.Doc fields would need a per-type
* field↔token map — exactly the trap this comparator avoids by construction.
*
* Tradeoff of the multiset rule: positional scalar tuples (e.g. `(at X Y)`) are
* compared as a multiset, so a pure X↔Y swap with otherwise-equal values would
* not be flagged. Acceptable for same-serializer inputs (the serializer emits a
* consistent order on both sides); revisit if a value-swap drift class appears.
*
* No imports — usable from standalone app code (`@/wasm/collab/sexpr-diff`) and
* from the Playwright specs (relative import) alike.
*/
/** Parsed node: a `string` is an atom (quoted strings keep their quotes, so a
* string value is distinct from the same bare token); an array is a list. */
export type SNode = string | SNode[];
export interface SexprChange {
uuid: string;
/** Best-effort location: the head keyword of the differing child, or "·atom"
* for a differing positional scalar. */
path: string;
/** The differing child as serialized on side A (the base/original), or null
* if absent on A. */
a: string | null;
/** The differing child as serialized on side B (the new), or null if absent. */
b: string | null;
}
export interface SexprDiffResult {
equal: boolean;
/** uuids present in B but not A. */
added: string[];
/** uuids present in A but not B. */
removed: string[];
/** per-property differences for uuids present in both. */
changed: SexprChange[];
}
export interface SexprDiffOptions {
/** Head keywords whose list nodes are dropped before comparing, at any depth —
* for known-volatile serializer output (e.g. "generator_version"). Declared
* explicitly so drift is never hidden silently. */
ignoreTokens?: string[];
}
// ── Parser ──────────────────────────────────────────────────────────────────
/** Parse s-expr text into a flat list of top-level forms (usually one). Throws
* on unbalanced parens or an unterminated string. */
export function parseSexpr(src: string): SNode[] {
let i = 0;
const n = src.length;
const isWs = (c: string | undefined) =>
c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
const skipWs = () => {
while (i < n && isWs(src[i])) i++;
};
const parseString = (): string => {
const start = i;
i++; // opening quote
while (i < n) {
const c = src[i];
if (c === "\\") {
i += 2; // escape — skip the next char too
continue;
}
if (c === '"') {
i++;
return src.slice(start, i); // keep surrounding quotes
}
i++;
}
throw new Error("sexprDiff: unterminated string");
};
const parseAtom = (): string => {
const start = i;
while (i < n && !isWs(src[i]) && src[i] !== "(" && src[i] !== ")" && src[i] !== '"') i++;
return src.slice(start, i);
};
const parseList = (): SNode[] => {
i++; // consume '('
const list: SNode[] = [];
for (;;) {
skipWs();
if (i >= n) throw new Error("sexprDiff: unbalanced parens (EOF inside list)");
const c = src[i];
if (c === ")") {
i++;
return list;
}
if (c === "(") list.push(parseList());
else if (c === '"') list.push(parseString());
else list.push(parseAtom());
}
};
const forms: SNode[] = [];
for (;;) {
skipWs();
if (i >= n) break;
if (src[i] === ")") throw new Error("sexprDiff: unbalanced parens (stray ')')");
if (src[i] === "(") forms.push(parseList());
else if (src[i] === '"') forms.push(parseString());
else forms.push(parseAtom());
}
return forms;
}
// ── uuid indexing ─────────────────────────────────────────────────────────────
function stripQuotes(atom: string): string {
return atom.length >= 2 && atom.startsWith('"') && atom.endsWith('"')
? atom.slice(1, -1)
: atom;
}
/** The uuid of a list node, from its direct `(uuid "X")` child, or null. */
function directUuid(node: SNode[]): string | null {
for (const c of node) {
if (Array.isArray(c) && c[0] === "uuid" && typeof c[1] === "string") {
return stripQuotes(c[1]);
}
}
return null;
}
/** Collect every list node that has a direct uuid child, keyed by uuid. */
function collectItems(node: SNode, out: Map<string, SNode[]>): void {
if (!Array.isArray(node)) return;
const id = directUuid(node);
if (id !== null && !out.has(id)) out.set(id, node);
for (const c of node) collectItems(c, out);
}
// ── Canonicalization (order-insensitive multiset) ──────────────────────────────
function isIgnored(node: SNode, ignore: Set<string>): boolean {
return Array.isArray(node) && typeof node[0] === "string" && ignore.has(node[0]);
}
/** Canonical string of a node: atoms verbatim, lists as `(` + sorted children + `)`. */
function canonical(node: SNode, ignore: Set<string>): string {
if (!Array.isArray(node)) return node;
const kids = node
.filter((c) => !isIgnored(c, ignore))
.map((c) => canonical(c, ignore))
.sort();
return "(" + kids.join(" ") + ")";
}
/** Group an item's direct children by head keyword, as canonical strings. */
function groupByHead(node: SNode[], ignore: Set<string>): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const c of node) {
if (isIgnored(c, ignore)) continue;
const head = Array.isArray(c) && typeof c[0] === "string" ? c[0] : "·atom";
const list = groups.get(head) ?? [];
list.push(canonical(c, ignore));
groups.set(head, list);
}
return groups;
}
/** Per-property changes between two item nodes sharing a uuid. */
function diffItem(uuid: string, a: SNode[], b: SNode[], ignore: Set<string>): SexprChange[] {
const ga = groupByHead(a, ignore);
const gb = groupByHead(b, ignore);
const heads = new Set<string>([...ga.keys(), ...gb.keys()]);
const out: SexprChange[] = [];
for (const head of heads) {
const as = (ga.get(head) ?? []).slice().sort();
const bs = (gb.get(head) ?? []).slice().sort();
// Multiset difference of the canonical child strings under this head.
const countB = new Map<string, number>();
for (const s of bs) countB.set(s, (countB.get(s) ?? 0) + 1);
const onlyA: string[] = [];
for (const s of as) {
const c = countB.get(s) ?? 0;
if (c > 0) countB.set(s, c - 1);
else onlyA.push(s);
}
const onlyB: string[] = [];
for (const [s, c] of countB) for (let k = 0; k < c; k++) onlyB.push(s);
const max = Math.max(onlyA.length, onlyB.length);
for (let k = 0; k < max; k++) {
out.push({ uuid, path: head, a: onlyA[k] ?? null, b: onlyB[k] ?? null });
}
}
return out;
}
// ── Public API ─────────────────────────────────────────────────────────────────
/**
* Structurally compare two s-expr texts by uuid-keyed items.
*
* @param a base / original text (e.g. the as-loaded model save)
* @param b new text (e.g. the rebuilt-from-Y.Doc model save)
*/
export function sexprDiff(a: string, b: string, opts: SexprDiffOptions = {}): SexprDiffResult {
const ignore = new Set(opts.ignoreTokens ?? []);
const itemsA = new Map<string, SNode[]>();
const itemsB = new Map<string, SNode[]>();
collectItems(parseSexpr(a), itemsA);
collectItems(parseSexpr(b), itemsB);
const removed: string[] = [];
const added: string[] = [];
const changed: SexprChange[] = [];
for (const id of itemsA.keys()) if (!itemsB.has(id)) removed.push(id);
for (const id of itemsB.keys()) if (!itemsA.has(id)) added.push(id);
for (const [id, na] of itemsA) {
const nb = itemsB.get(id);
if (!nb) continue;
if (canonical(na, ignore) !== canonical(nb, ignore)) {
changed.push(...diffItem(id, na, nb, ignore));
}
}
const equal = added.length === 0 && removed.length === 0 && changed.length === 0;
return { equal, added: added.sort(), removed: removed.sort(), changed };
}

View file

@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
// Standalone unit-test config, intentionally separate from vite.config.ts so the
// test run doesn't pull in the React plugin / WASM-asset dev server. Pure-logic
// modules (e.g. the sexpr-diff comparator) run in the node environment.
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});