@praxisflux/gates 0.58.0 → 0.59.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ // board-mirror.mjs — the tracked board mirror: one schema every board provider projects into.
2
+ //
3
+ // `.board/links.json` at the project root — TRACKED in git (it is evidence, not transport;
4
+ // contrast the gitignored `.handoff/` transport). Shape:
5
+ //
6
+ // {
7
+ // "schema": 1,
8
+ // "provider": "backlog",
9
+ // "generatedAt": "<ISO 8601>",
10
+ // "links": [
11
+ // { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
12
+ // "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
13
+ // "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
14
+ // ]
15
+ // }
16
+ //
17
+ // `id` / `status` / `specDir` / `acs` are exactly spec-bridge's per-task shape (see
18
+ // `findLinkedTasks` in `spec-bridge/gates/bridge.mjs`) minus `file` — the verdict engine's
19
+ // input is unchanged in substance, so a later spec can swap the source without touching the
20
+ // logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
21
+ // (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
22
+ //
23
+ // `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
24
+ // never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
25
+ // top-level and per-link keys round-trip: read a mirror, write it back, and every key this
26
+ // module doesn't know about comes back unchanged — so a future provider can add fields
27
+ // without this version destroying them.
28
+ //
29
+ // `writeMirror` is BYTE-DETERMINISTIC: explicit key order, 2-space indent, trailing newline,
30
+ // `links` sorted by natural id order (`TASK-9` before `TASK-10`, `TASK-6.2` before
31
+ // `TASK-6.10`). `generatedAt` is a timestamp, so a caller comparing two writes for drift
32
+ // (the `--check` CLI, phase 4) must normalize it on both sides first — this module does not
33
+ // exclude it from what it writes.
34
+ //
35
+ // Zero dependencies, pure Node, no network (lib/README.md convention).
36
+ //
37
+ // Dual-use: `node lib/board-mirror.mjs --check --root <dir>` mechanizes drift for a
38
+ // deterministic provider and staleness for a model-backed one (see the CLI block below).
39
+ //
40
+ // `.board.json` (spec 054) is the OTHER file this module owns, and a different question from
41
+ // the mirror above: `.board/links.json` is BOARD STATE (what's linked, its status);
42
+ // `.board.json` is BOARD CONFIG (which provider a project uses, and that provider's
43
+ // coordinates). Tracked, hand-editable, at the project root, outside every marker — same
44
+ // posture as `.claude/model-tiers.json` (plant only when absent; doctrine points at the config;
45
+ // the config is what you edit, not something generated). `loadBoardConfig`/`validateBoardConfig`
46
+ // near the `providers` registry below read/check it.
47
+ //
48
+ // `.board.json`'s `statusMap` (bridge status -> site workflow status) composes with
49
+ // `.spec-bridge.json`'s `statusVocabulary` (derivation stage -> bridge status, bridge.mjs:69)
50
+ // at a DIFFERENT layer — the two do not merge, and this is the written precedence:
51
+ //
52
+ // derivation stage ──statusVocabulary──▶ bridge status ──statusMap──▶ site workflow status
53
+ // (reviewing) ("In Review") ("In Review")
54
+ //
55
+ // Unmapped statuses fall through unchanged at either layer. Neither file's meaning changes;
56
+ // this is only the stated relationship between them (two undocumented status mappings is a bug
57
+ // factory).
58
+
59
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
60
+ import { join, dirname, resolve } from "node:path";
61
+ import { spawnSync } from "node:child_process";
62
+ import { runAsCli } from "./cli.mjs";
63
+
64
+ /** The only schema this module understands. */
65
+ export const CURRENT_SCHEMA = 1;
66
+
67
+ /** Where the mirror lives, relative to a project root. */
68
+ export function mirrorPath(root) {
69
+ return join(root, ".board", "links.json");
70
+ }
71
+
72
+ /**
73
+ * Natural comparator for board ids such as "TASK-9", "TASK-10", "TASK-6.2", "TASK-6.10":
74
+ * splits each id into runs of digits vs. non-digits and compares digit runs numerically, so
75
+ * "TASK-9" sorts before "TASK-10" and "TASK-6.2" before "TASK-6.10" — a plain string sort
76
+ * gets both wrong (dotted subtask ids are real board shapes, per plan.md).
77
+ */
78
+ export function compareIds(a, b) {
79
+ const parts = (s) => String(s).match(/\d+|\D+/g) || [];
80
+ const as = parts(a);
81
+ const bs = parts(b);
82
+ const len = Math.max(as.length, bs.length);
83
+ for (let i = 0; i < len; i++) {
84
+ const x = as[i] ?? "";
85
+ const y = bs[i] ?? "";
86
+ if (x === y) continue;
87
+ const xNum = /^\d+$/.test(x);
88
+ const yNum = /^\d+$/.test(y);
89
+ if (xNum && yNum) {
90
+ const d = Number(x) - Number(y);
91
+ if (d) return d;
92
+ continue;
93
+ }
94
+ return x < y ? -1 : 1;
95
+ }
96
+ return 0;
97
+ }
98
+
99
+ /** Read `<root>/.board/links.json`. `null` when absent; throws on malformed JSON or an
100
+ * unrecognized `schema` — a broken mirror is a blocking problem, never an empty board. */
101
+ export function readMirror(root) {
102
+ const path = mirrorPath(root);
103
+ if (!existsSync(path)) return null;
104
+ const raw = readFileSync(path, "utf8");
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(raw);
108
+ } catch (e) {
109
+ throw new Error(`${path}: malformed JSON (${e.message})`);
110
+ }
111
+ if (parsed?.schema !== CURRENT_SCHEMA) {
112
+ throw new Error(`${path}: unknown schema ${JSON.stringify(parsed?.schema)} (this module knows schema ${CURRENT_SCHEMA})`);
113
+ }
114
+ return parsed;
115
+ }
116
+
117
+ const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
118
+ const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
119
+ const AC_KEYS = ["index", "checked", "text"];
120
+
121
+ /** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
122
+ * after, in its original enumeration order — this is what makes unknown keys round-trip. */
123
+ function orderedObject(obj, knownKeys) {
124
+ const out = {};
125
+ for (const k of knownKeys) if (Object.prototype.hasOwnProperty.call(obj, k)) out[k] = obj[k];
126
+ for (const k of Object.keys(obj)) if (!knownKeys.includes(k)) out[k] = obj[k];
127
+ return out;
128
+ }
129
+
130
+ /** Pure serialization shared by `writeMirror` and the `--check` CLI's byte comparison:
131
+ * explicit schema key order, 2-space indent, trailing newline, `links` sorted by natural id
132
+ * order. Does not touch disk. */
133
+ function serializeMirror(mirror) {
134
+ const links = [...(mirror.links || [])]
135
+ .slice()
136
+ .sort((a, b) => compareIds(a.id, b.id))
137
+ .map((link) => {
138
+ const ordered = orderedObject(link, LINK_KEYS);
139
+ if (Array.isArray(ordered.acs)) ordered.acs = ordered.acs.map((ac) => orderedObject(ac, AC_KEYS));
140
+ return ordered;
141
+ });
142
+ const out = orderedObject({ ...mirror, links }, TOP_KEYS);
143
+ return JSON.stringify(out, null, 2) + "\n";
144
+ }
145
+
146
+ /** Write a mirror deterministically (see `serializeMirror`). Creates `.board/` if absent.
147
+ * Returns the path written. */
148
+ export function writeMirror(root, mirror) {
149
+ const path = mirrorPath(root);
150
+ mkdirSync(dirname(path), { recursive: true });
151
+ writeFileSync(path, serializeMirror(mirror));
152
+ return path;
153
+ }
154
+
155
+ /**
156
+ * Validate a mirror object (as returned by `readMirror` or built in memory). Returns
157
+ * human-readable problems, empty when valid. Checks every required field's presence and
158
+ * type, `acs` index monotonicity, and that no two links share an `id` or a `specDir` (one
159
+ * card per spec dir is the bridge's existing contract).
160
+ */
161
+ export function validateMirror(mirror) {
162
+ const problems = [];
163
+ const req = (val, name, type) => {
164
+ const ok = type === "array" ? Array.isArray(val) : typeof val === type;
165
+ if (!ok) problems.push(`${name}: expected ${type}, got ${val === undefined ? "missing" : typeof val}`);
166
+ return ok;
167
+ };
168
+
169
+ if (!mirror || typeof mirror !== "object") {
170
+ problems.push("mirror: expected object");
171
+ return problems;
172
+ }
173
+ req(mirror.schema, "schema", "number");
174
+ req(mirror.provider, "provider", "string");
175
+ req(mirror.generatedAt, "generatedAt", "string");
176
+ if (!req(mirror.links, "links", "array")) return problems;
177
+
178
+ const seenIds = new Set();
179
+ const seenSpecDirs = new Set();
180
+ mirror.links.forEach((link, i) => {
181
+ const where = `links[${i}]`;
182
+ if (!link || typeof link !== "object") { problems.push(`${where}: expected object`); return; }
183
+ req(link.id, `${where}.id`, "string");
184
+ req(link.status, `${where}.status`, "string");
185
+ req(link.specDir, `${where}.specDir`, "string");
186
+
187
+ if (typeof link.id === "string") {
188
+ if (seenIds.has(link.id)) problems.push(`duplicate id: ${link.id}`);
189
+ seenIds.add(link.id);
190
+ }
191
+ if (typeof link.specDir === "string") {
192
+ if (seenSpecDirs.has(link.specDir)) problems.push(`duplicate specDir: ${link.specDir}`);
193
+ seenSpecDirs.add(link.specDir);
194
+ }
195
+
196
+ if (!req(link.acs, `${where}.acs`, "array")) return;
197
+ let prev = -Infinity;
198
+ link.acs.forEach((ac, j) => {
199
+ const acWhere = `${where}.acs[${j}]`;
200
+ if (!ac || typeof ac !== "object") { problems.push(`${acWhere}: expected object`); return; }
201
+ req(ac.index, `${acWhere}.index`, "number");
202
+ req(ac.checked, `${acWhere}.checked`, "boolean");
203
+ req(ac.text, `${acWhere}.text`, "string");
204
+ if (typeof ac.index === "number") {
205
+ if (ac.index <= prev) problems.push(`${where}.acs index not monotonic increasing at [${j}] (${ac.index} after ${prev})`);
206
+ prev = ac.index;
207
+ }
208
+ });
209
+ });
210
+
211
+ return problems;
212
+ }
213
+
214
+ /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
+ *
216
+ * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
217
+ * time either is patched. `bridge.mjs` re-exports both symbols so every existing import site
218
+ * still resolves. */
219
+
220
+ const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
+
222
+ /**
223
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
224
+ * null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
225
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
226
+ * still read-only; the plan command needs them to compute reconciling edits.
227
+ */
228
+ export function parseLinkedTask(raw) {
229
+ const text = String(raw ?? "");
230
+ const marker = text.match(MARKER);
231
+ if (!marker) return null;
232
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
233
+ if (!fm) return null;
234
+ const field = (name) => fm[1].match(new RegExp(`^${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "") ?? "";
235
+ const id = field("id");
236
+ const status = field("status");
237
+ if (!id) return null;
238
+ const acs = [];
239
+ const block = text.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/);
240
+ if (block)
241
+ for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
+ acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
+ return { id, status, specDir: marker[1], acs };
244
+ }
245
+
246
+ /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
247
+ export function findLinkedTasks(root) {
248
+ const dir = join(root, "backlog", "tasks");
249
+ let entries = [];
250
+ try { entries = readdirSync(dir); } catch { return []; }
251
+ const linked = [];
252
+ for (const name of entries.filter((n) => n.endsWith(".md")).sort()) {
253
+ try {
254
+ const task = parseLinkedTask(readFileSync(join(dir, name), "utf8"));
255
+ if (task) linked.push({ ...task, file: join(dir, name) });
256
+ } catch { /* skip unreadable */ }
257
+ }
258
+ return linked;
259
+ }
260
+
261
+ /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
+ * exactly the mirror's per-link fields (drops `file`). */
263
+ export function projectBacklog(root) {
264
+ return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
265
+ }
266
+
267
+ /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
268
+ * `requiresSync: false` — the projection is deterministic; `project(root)` recomputes it and
269
+ * `--check` can diff it byte-for-byte against the on-disk mirror.
270
+ * `requiresSync: true` — the projection needs a model (MCP-backed boards); `project` is
271
+ * `null` because no `node`-only recompute exists, and `--check` can only assess staleness.
272
+ * The *type* of `project` (function vs. `null`) carries the distinction — spec 056 adds
273
+ * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
274
+ export const providers = {
275
+ backlog: { requiresSync: false, project: projectBacklog },
276
+ };
277
+
278
+ /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
279
+ * above. `providers` is the projector registry (spec 052/056): provider name -> how to
280
+ * recompute the mirror. This table only knows the config-schema shape — which provider names
281
+ * are legal and which fields each requires — so `.board.json` can be validated today without
282
+ * pre-empting spec 056's ownership of adding `jira`'s projector entry to `providers`. Do NOT
283
+ * fold these two tables into one: that would re-couple config validation to projector
284
+ * implementation, which is exactly the coupling keeping them apart avoids. */
285
+ const BOARD_CONFIG_PROVIDERS = {
286
+ backlog: { requiredFields: [] },
287
+ jira: { requiredFields: ["cloudId", "projectKey", "issueTypeName"] },
288
+ };
289
+
290
+ /** Load `<root>/.board.json`. Returns `{ provider: "backlog" }` when absent — spec 053's
291
+ * backward-compatible default. Throws on malformed JSON (fail closed). Throws naming the known
292
+ * providers on an unknown `provider` value — NEVER falls back to `backlog`: silently treating
293
+ * a Jira project as a Backlog project is the exact silent no-op this feature exists to
294
+ * remove. */
295
+ export function loadBoardConfig(root) {
296
+ const path = join(root, ".board.json");
297
+ if (!existsSync(path)) return { provider: "backlog" };
298
+ const raw = readFileSync(path, "utf8");
299
+ let config;
300
+ try {
301
+ config = JSON.parse(raw);
302
+ } catch (e) {
303
+ throw new Error(`${path}: malformed JSON (${e.message})`);
304
+ }
305
+ const name = config?.provider;
306
+ if (!BOARD_CONFIG_PROVIDERS[name])
307
+ throw new Error(`${path}: unknown board provider ${JSON.stringify(name)} (known: ${Object.keys(BOARD_CONFIG_PROVIDERS).join(", ")})`);
308
+ return config;
309
+ }
310
+
311
+ /** Validate a `.board.json` object (as returned by `loadBoardConfig` or built in memory).
312
+ * Returns human-readable problems, empty when valid. Catches: `provider` as an array (one
313
+ * board is the plan of record — a list is a validation error saying why) or other non-string,
314
+ * an unknown provider name, a known provider missing one of its required fields (`jira` needs
315
+ * `cloudId`/`projectKey`/`issueTypeName`), and a non-object `statusMap`. */
316
+ export function validateBoardConfig(config) {
317
+ const problems = [];
318
+ if (!config || typeof config !== "object") {
319
+ problems.push("config: expected object");
320
+ return problems;
321
+ }
322
+ const name = config.provider;
323
+ if (Array.isArray(name)) {
324
+ problems.push("provider: expected a single string, got an array (one board is the plan of record)");
325
+ } else if (typeof name !== "string") {
326
+ problems.push(`provider: expected string, got ${name === undefined ? "missing" : typeof name}`);
327
+ } else if (!BOARD_CONFIG_PROVIDERS[name]) {
328
+ problems.push(`provider: unknown board provider "${name}" (known: ${Object.keys(BOARD_CONFIG_PROVIDERS).join(", ")})`);
329
+ } else {
330
+ const { requiredFields } = BOARD_CONFIG_PROVIDERS[name];
331
+ const sub = config[name] || {};
332
+ for (const field of requiredFields)
333
+ if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
334
+ if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
335
+ problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
336
+ }
337
+ return problems;
338
+ }
339
+
340
+ /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
341
+ * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
342
+ * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
343
+ * when the process itself could not be spawned (e.g. `git` missing). */
344
+ function runGit(cwd, args) {
345
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
346
+ if (r.error) return { status: null, out: "" };
347
+ return { status: r.status, out: (r.stdout || "").trim() };
348
+ }
349
+
350
+ /**
351
+ * Is a mirror stale? Fail-closed per R3/docs/wiki/gates-convention.md: every unknown resolves
352
+ * to `stale: true` with a stated reason, never a silent "looks fine".
353
+ *
354
+ * Three cases produce `stale: true`:
355
+ * 1. a link's `observedSha` is not an ancestor of `headSha` (mirror observed on history this
356
+ * tree no longer contains, or a foreign/rewritten history);
357
+ * 2. a link has no `observedSha` and its provider is `requiresSync: true` (a receipt-less
358
+ * MCP-backed mirror is not evidence);
359
+ * 3. `root` is not a git repo, or the sha is unknown to it — `git merge-base --is-ancestor`
360
+ * cannot answer, so no honest claim of freshness exists.
361
+ *
362
+ * Ancestry uses `git merge-base --is-ancestor <sha> <headSha>` (exit 0 = ancestor, exit 1 =
363
+ * not an ancestor, anything else = unknown/error) via `runGit`, the same spawnSync shape
364
+ * grounding-wiki's freshness gate uses for pins. `headSha` defaults to `"HEAD"` so a caller
365
+ * that already has the working tree's HEAD checked out need not resolve it separately.
366
+ */
367
+ export function mirrorStaleness(root, mirror, { headSha = "HEAD" } = {}) {
368
+ if (!mirror || !Array.isArray(mirror.links)) return { stale: false, reason: null };
369
+ const provider = providers[mirror.provider];
370
+ // Unknown provider name: fail closed rather than assume it is safely deterministic.
371
+ const requiresSync = provider ? provider.requiresSync : true;
372
+
373
+ for (const link of mirror.links) {
374
+ if (!link.observedSha) {
375
+ if (requiresSync)
376
+ return { stale: true, reason: `${link.id}: no observedSha on requiresSync provider "${mirror.provider}"` };
377
+ continue;
378
+ }
379
+ const r = runGit(root, ["merge-base", "--is-ancestor", link.observedSha, headSha]);
380
+ if (r.status === 1)
381
+ return { stale: true, reason: `${link.id}: observedSha ${link.observedSha} is not an ancestor of ${headSha}` };
382
+ if (r.status !== 0)
383
+ return { stale: true, reason: `${link.id}: cannot verify observedSha ${link.observedSha} (not a git repo, or the sha is unknown)` };
384
+ }
385
+ return { stale: false, reason: null };
386
+ }
387
+
388
+ /* ── --check CLI (spec 052 R5), dual-use via lib/cli.mjs's runAsCli guard ──
389
+ *
390
+ * node lib/board-mirror.mjs --check --root <dir>
391
+ *
392
+ * Exit codes match spec-bridge/gates/cli.mjs's convention: 0 clean, 1 findings, 2 env error
393
+ * (unreadable root, unknown provider, usage error). A malformed/unknown-schema mirror is a
394
+ * FINDING (1), not an env error — it is the artifact that is broken, not the invocation.
395
+ *
396
+ * requiresSync: false — recompute the provider's projection and byte-compare it against the
397
+ * on-disk mirror via `serializeMirror`, with `generatedAt` normalized on both sides (it's a
398
+ * timestamp, not a fact to diff). requiresSync: true — cannot recompute; validate + check
399
+ * staleness only. No mirror at all — exit 0, "no mirror; nothing to check" (a project that
400
+ * hasn't adopted the seam is not in violation of it). */
401
+ if (runAsCli(import.meta.url)) {
402
+ const args = process.argv.slice(2);
403
+ const rootIdx = args.indexOf("--root");
404
+ if (!args.includes("--check") || rootIdx === -1 || !args[rootIdx + 1]) {
405
+ console.error("usage: node lib/board-mirror.mjs --check --root <dir>");
406
+ process.exit(2);
407
+ }
408
+ const root = resolve(args[rootIdx + 1]);
409
+ if (!existsSync(root)) {
410
+ console.error(`board-mirror: root not found: ${root}`);
411
+ process.exit(2);
412
+ }
413
+
414
+ let mirror;
415
+ try {
416
+ mirror = readMirror(root);
417
+ } catch (e) {
418
+ console.log(`board-mirror check FAILED: ${e.message}`);
419
+ process.exit(1);
420
+ }
421
+
422
+ if (!mirror) {
423
+ console.log("board-mirror: no mirror; nothing to check");
424
+ process.exit(0);
425
+ }
426
+
427
+ const problems = validateMirror(mirror);
428
+ if (problems.length) {
429
+ console.log(`board-mirror check FAILED (${problems.length} issue(s)):`);
430
+ for (const p of problems) console.log(` - ${p}`);
431
+ process.exit(1);
432
+ }
433
+
434
+ const provider = providers[mirror.provider];
435
+ if (!provider) {
436
+ console.error(`board-mirror: unknown provider "${mirror.provider}"`);
437
+ process.exit(2);
438
+ }
439
+
440
+ if (provider.requiresSync) {
441
+ const { stale, reason } = mirrorStaleness(root, mirror);
442
+ if (stale) {
443
+ console.log(`board-mirror check FAILED: stale — ${reason}`);
444
+ process.exit(1);
445
+ }
446
+ console.log(`board-mirror ok: ${mirror.provider} mirror valid, not stale (requiresSync — drift not recomputable)`);
447
+ process.exit(0);
448
+ }
449
+
450
+ const recomputed = { ...mirror, links: provider.project(root), generatedAt: "" };
451
+ const onDisk = { ...mirror, generatedAt: "" };
452
+ const expected = serializeMirror(onDisk);
453
+ const actual = serializeMirror(recomputed);
454
+ if (expected !== actual) {
455
+ const strip = (l) => ({ id: l.id, status: l.status, specDir: l.specDir, acs: l.acs });
456
+ const byId = (arr) => Object.fromEntries(arr.map((l) => [l.id, l]));
457
+ const a = byId(onDisk.links || []);
458
+ const b = byId(recomputed.links);
459
+ const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
460
+ const drifted = [...ids].filter((id) => JSON.stringify(a[id] && strip(a[id])) !== JSON.stringify(b[id] && strip(b[id])));
461
+ console.log(`board-mirror check FAILED: mirror drifted from the recomputed "${mirror.provider}" projection`);
462
+ for (const id of drifted.sort(compareIds)) console.log(` - ${id}: on-disk mirror does not match the recomputed projection`);
463
+ process.exit(1);
464
+ }
465
+ console.log(`board-mirror ok: ${mirror.provider} mirror matches the recomputed projection (${recomputed.links.length} link(s))`);
466
+ process.exit(0);
467
+ }