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:
parent
0773acefaa
commit
f29ceb4964
5 changed files with 706 additions and 2 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
web/standalone/src/wasm/collab/sexpr-diff.test.ts
Normal file
110
web/standalone/src/wasm/collab/sexpr-diff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
252
web/standalone/src/wasm/collab/sexpr-diff.ts
Normal file
252
web/standalone/src/wasm/collab/sexpr-diff.ts
Normal 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 };
|
||||
}
|
||||
15
web/standalone/vitest.config.ts
Normal file
15
web/standalone/vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue