@praxisflux/gates 0.58.0 → 0.59.0

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,386 @@
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
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
41
+ import { join, dirname, resolve } from "node:path";
42
+ import { spawnSync } from "node:child_process";
43
+ import { runAsCli } from "./cli.mjs";
44
+
45
+ /** The only schema this module understands. */
46
+ export const CURRENT_SCHEMA = 1;
47
+
48
+ /** Where the mirror lives, relative to a project root. */
49
+ export function mirrorPath(root) {
50
+ return join(root, ".board", "links.json");
51
+ }
52
+
53
+ /**
54
+ * Natural comparator for board ids such as "TASK-9", "TASK-10", "TASK-6.2", "TASK-6.10":
55
+ * splits each id into runs of digits vs. non-digits and compares digit runs numerically, so
56
+ * "TASK-9" sorts before "TASK-10" and "TASK-6.2" before "TASK-6.10" — a plain string sort
57
+ * gets both wrong (dotted subtask ids are real board shapes, per plan.md).
58
+ */
59
+ export function compareIds(a, b) {
60
+ const parts = (s) => String(s).match(/\d+|\D+/g) || [];
61
+ const as = parts(a);
62
+ const bs = parts(b);
63
+ const len = Math.max(as.length, bs.length);
64
+ for (let i = 0; i < len; i++) {
65
+ const x = as[i] ?? "";
66
+ const y = bs[i] ?? "";
67
+ if (x === y) continue;
68
+ const xNum = /^\d+$/.test(x);
69
+ const yNum = /^\d+$/.test(y);
70
+ if (xNum && yNum) {
71
+ const d = Number(x) - Number(y);
72
+ if (d) return d;
73
+ continue;
74
+ }
75
+ return x < y ? -1 : 1;
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ /** Read `<root>/.board/links.json`. `null` when absent; throws on malformed JSON or an
81
+ * unrecognized `schema` — a broken mirror is a blocking problem, never an empty board. */
82
+ export function readMirror(root) {
83
+ const path = mirrorPath(root);
84
+ if (!existsSync(path)) return null;
85
+ const raw = readFileSync(path, "utf8");
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ } catch (e) {
90
+ throw new Error(`${path}: malformed JSON (${e.message})`);
91
+ }
92
+ if (parsed?.schema !== CURRENT_SCHEMA) {
93
+ throw new Error(`${path}: unknown schema ${JSON.stringify(parsed?.schema)} (this module knows schema ${CURRENT_SCHEMA})`);
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
99
+ const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
100
+ const AC_KEYS = ["index", "checked", "text"];
101
+
102
+ /** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
103
+ * after, in its original enumeration order — this is what makes unknown keys round-trip. */
104
+ function orderedObject(obj, knownKeys) {
105
+ const out = {};
106
+ for (const k of knownKeys) if (Object.prototype.hasOwnProperty.call(obj, k)) out[k] = obj[k];
107
+ for (const k of Object.keys(obj)) if (!knownKeys.includes(k)) out[k] = obj[k];
108
+ return out;
109
+ }
110
+
111
+ /** Pure serialization shared by `writeMirror` and the `--check` CLI's byte comparison:
112
+ * explicit schema key order, 2-space indent, trailing newline, `links` sorted by natural id
113
+ * order. Does not touch disk. */
114
+ function serializeMirror(mirror) {
115
+ const links = [...(mirror.links || [])]
116
+ .slice()
117
+ .sort((a, b) => compareIds(a.id, b.id))
118
+ .map((link) => {
119
+ const ordered = orderedObject(link, LINK_KEYS);
120
+ if (Array.isArray(ordered.acs)) ordered.acs = ordered.acs.map((ac) => orderedObject(ac, AC_KEYS));
121
+ return ordered;
122
+ });
123
+ const out = orderedObject({ ...mirror, links }, TOP_KEYS);
124
+ return JSON.stringify(out, null, 2) + "\n";
125
+ }
126
+
127
+ /** Write a mirror deterministically (see `serializeMirror`). Creates `.board/` if absent.
128
+ * Returns the path written. */
129
+ export function writeMirror(root, mirror) {
130
+ const path = mirrorPath(root);
131
+ mkdirSync(dirname(path), { recursive: true });
132
+ writeFileSync(path, serializeMirror(mirror));
133
+ return path;
134
+ }
135
+
136
+ /**
137
+ * Validate a mirror object (as returned by `readMirror` or built in memory). Returns
138
+ * human-readable problems, empty when valid. Checks every required field's presence and
139
+ * type, `acs` index monotonicity, and that no two links share an `id` or a `specDir` (one
140
+ * card per spec dir is the bridge's existing contract).
141
+ */
142
+ export function validateMirror(mirror) {
143
+ const problems = [];
144
+ const req = (val, name, type) => {
145
+ const ok = type === "array" ? Array.isArray(val) : typeof val === type;
146
+ if (!ok) problems.push(`${name}: expected ${type}, got ${val === undefined ? "missing" : typeof val}`);
147
+ return ok;
148
+ };
149
+
150
+ if (!mirror || typeof mirror !== "object") {
151
+ problems.push("mirror: expected object");
152
+ return problems;
153
+ }
154
+ req(mirror.schema, "schema", "number");
155
+ req(mirror.provider, "provider", "string");
156
+ req(mirror.generatedAt, "generatedAt", "string");
157
+ if (!req(mirror.links, "links", "array")) return problems;
158
+
159
+ const seenIds = new Set();
160
+ const seenSpecDirs = new Set();
161
+ mirror.links.forEach((link, i) => {
162
+ const where = `links[${i}]`;
163
+ if (!link || typeof link !== "object") { problems.push(`${where}: expected object`); return; }
164
+ req(link.id, `${where}.id`, "string");
165
+ req(link.status, `${where}.status`, "string");
166
+ req(link.specDir, `${where}.specDir`, "string");
167
+
168
+ if (typeof link.id === "string") {
169
+ if (seenIds.has(link.id)) problems.push(`duplicate id: ${link.id}`);
170
+ seenIds.add(link.id);
171
+ }
172
+ if (typeof link.specDir === "string") {
173
+ if (seenSpecDirs.has(link.specDir)) problems.push(`duplicate specDir: ${link.specDir}`);
174
+ seenSpecDirs.add(link.specDir);
175
+ }
176
+
177
+ if (!req(link.acs, `${where}.acs`, "array")) return;
178
+ let prev = -Infinity;
179
+ link.acs.forEach((ac, j) => {
180
+ const acWhere = `${where}.acs[${j}]`;
181
+ if (!ac || typeof ac !== "object") { problems.push(`${acWhere}: expected object`); return; }
182
+ req(ac.index, `${acWhere}.index`, "number");
183
+ req(ac.checked, `${acWhere}.checked`, "boolean");
184
+ req(ac.text, `${acWhere}.text`, "string");
185
+ if (typeof ac.index === "number") {
186
+ if (ac.index <= prev) problems.push(`${where}.acs index not monotonic increasing at [${j}] (${ac.index} after ${prev})`);
187
+ prev = ac.index;
188
+ }
189
+ });
190
+ });
191
+
192
+ return problems;
193
+ }
194
+
195
+ /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
196
+ *
197
+ * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
198
+ * time either is patched. `bridge.mjs` re-exports both symbols so every existing import site
199
+ * still resolves. */
200
+
201
+ const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
202
+
203
+ /**
204
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
205
+ * null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
206
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
207
+ * still read-only; the plan command needs them to compute reconciling edits.
208
+ */
209
+ export function parseLinkedTask(raw) {
210
+ const text = String(raw ?? "");
211
+ const marker = text.match(MARKER);
212
+ if (!marker) return null;
213
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
214
+ if (!fm) return null;
215
+ const field = (name) => fm[1].match(new RegExp(`^${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "") ?? "";
216
+ const id = field("id");
217
+ const status = field("status");
218
+ if (!id) return null;
219
+ const acs = [];
220
+ const block = text.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/);
221
+ if (block)
222
+ for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
223
+ acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
224
+ return { id, status, specDir: marker[1], acs };
225
+ }
226
+
227
+ /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
228
+ export function findLinkedTasks(root) {
229
+ const dir = join(root, "backlog", "tasks");
230
+ let entries = [];
231
+ try { entries = readdirSync(dir); } catch { return []; }
232
+ const linked = [];
233
+ for (const name of entries.filter((n) => n.endsWith(".md")).sort()) {
234
+ try {
235
+ const task = parseLinkedTask(readFileSync(join(dir, name), "utf8"));
236
+ if (task) linked.push({ ...task, file: join(dir, name) });
237
+ } catch { /* skip unreadable */ }
238
+ }
239
+ return linked;
240
+ }
241
+
242
+ /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
243
+ * exactly the mirror's per-link fields (drops `file`). */
244
+ export function projectBacklog(root) {
245
+ return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
246
+ }
247
+
248
+ /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
249
+ * `requiresSync: false` — the projection is deterministic; `project(root)` recomputes it and
250
+ * `--check` can diff it byte-for-byte against the on-disk mirror.
251
+ * `requiresSync: true` — the projection needs a model (MCP-backed boards); `project` is
252
+ * `null` because no `node`-only recompute exists, and `--check` can only assess staleness.
253
+ * The *type* of `project` (function vs. `null`) carries the distinction — spec 056 adds
254
+ * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
255
+ export const providers = {
256
+ backlog: { requiresSync: false, project: projectBacklog },
257
+ };
258
+
259
+ /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
260
+ * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
261
+ * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
262
+ * when the process itself could not be spawned (e.g. `git` missing). */
263
+ function runGit(cwd, args) {
264
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
265
+ if (r.error) return { status: null, out: "" };
266
+ return { status: r.status, out: (r.stdout || "").trim() };
267
+ }
268
+
269
+ /**
270
+ * Is a mirror stale? Fail-closed per R3/docs/wiki/gates-convention.md: every unknown resolves
271
+ * to `stale: true` with a stated reason, never a silent "looks fine".
272
+ *
273
+ * Three cases produce `stale: true`:
274
+ * 1. a link's `observedSha` is not an ancestor of `headSha` (mirror observed on history this
275
+ * tree no longer contains, or a foreign/rewritten history);
276
+ * 2. a link has no `observedSha` and its provider is `requiresSync: true` (a receipt-less
277
+ * MCP-backed mirror is not evidence);
278
+ * 3. `root` is not a git repo, or the sha is unknown to it — `git merge-base --is-ancestor`
279
+ * cannot answer, so no honest claim of freshness exists.
280
+ *
281
+ * Ancestry uses `git merge-base --is-ancestor <sha> <headSha>` (exit 0 = ancestor, exit 1 =
282
+ * not an ancestor, anything else = unknown/error) via `runGit`, the same spawnSync shape
283
+ * grounding-wiki's freshness gate uses for pins. `headSha` defaults to `"HEAD"` so a caller
284
+ * that already has the working tree's HEAD checked out need not resolve it separately.
285
+ */
286
+ export function mirrorStaleness(root, mirror, { headSha = "HEAD" } = {}) {
287
+ if (!mirror || !Array.isArray(mirror.links)) return { stale: false, reason: null };
288
+ const provider = providers[mirror.provider];
289
+ // Unknown provider name: fail closed rather than assume it is safely deterministic.
290
+ const requiresSync = provider ? provider.requiresSync : true;
291
+
292
+ for (const link of mirror.links) {
293
+ if (!link.observedSha) {
294
+ if (requiresSync)
295
+ return { stale: true, reason: `${link.id}: no observedSha on requiresSync provider "${mirror.provider}"` };
296
+ continue;
297
+ }
298
+ const r = runGit(root, ["merge-base", "--is-ancestor", link.observedSha, headSha]);
299
+ if (r.status === 1)
300
+ return { stale: true, reason: `${link.id}: observedSha ${link.observedSha} is not an ancestor of ${headSha}` };
301
+ if (r.status !== 0)
302
+ return { stale: true, reason: `${link.id}: cannot verify observedSha ${link.observedSha} (not a git repo, or the sha is unknown)` };
303
+ }
304
+ return { stale: false, reason: null };
305
+ }
306
+
307
+ /* ── --check CLI (spec 052 R5), dual-use via lib/cli.mjs's runAsCli guard ──
308
+ *
309
+ * node lib/board-mirror.mjs --check --root <dir>
310
+ *
311
+ * Exit codes match spec-bridge/gates/cli.mjs's convention: 0 clean, 1 findings, 2 env error
312
+ * (unreadable root, unknown provider, usage error). A malformed/unknown-schema mirror is a
313
+ * FINDING (1), not an env error — it is the artifact that is broken, not the invocation.
314
+ *
315
+ * requiresSync: false — recompute the provider's projection and byte-compare it against the
316
+ * on-disk mirror via `serializeMirror`, with `generatedAt` normalized on both sides (it's a
317
+ * timestamp, not a fact to diff). requiresSync: true — cannot recompute; validate + check
318
+ * staleness only. No mirror at all — exit 0, "no mirror; nothing to check" (a project that
319
+ * hasn't adopted the seam is not in violation of it). */
320
+ if (runAsCli(import.meta.url)) {
321
+ const args = process.argv.slice(2);
322
+ const rootIdx = args.indexOf("--root");
323
+ if (!args.includes("--check") || rootIdx === -1 || !args[rootIdx + 1]) {
324
+ console.error("usage: node lib/board-mirror.mjs --check --root <dir>");
325
+ process.exit(2);
326
+ }
327
+ const root = resolve(args[rootIdx + 1]);
328
+ if (!existsSync(root)) {
329
+ console.error(`board-mirror: root not found: ${root}`);
330
+ process.exit(2);
331
+ }
332
+
333
+ let mirror;
334
+ try {
335
+ mirror = readMirror(root);
336
+ } catch (e) {
337
+ console.log(`board-mirror check FAILED: ${e.message}`);
338
+ process.exit(1);
339
+ }
340
+
341
+ if (!mirror) {
342
+ console.log("board-mirror: no mirror; nothing to check");
343
+ process.exit(0);
344
+ }
345
+
346
+ const problems = validateMirror(mirror);
347
+ if (problems.length) {
348
+ console.log(`board-mirror check FAILED (${problems.length} issue(s)):`);
349
+ for (const p of problems) console.log(` - ${p}`);
350
+ process.exit(1);
351
+ }
352
+
353
+ const provider = providers[mirror.provider];
354
+ if (!provider) {
355
+ console.error(`board-mirror: unknown provider "${mirror.provider}"`);
356
+ process.exit(2);
357
+ }
358
+
359
+ if (provider.requiresSync) {
360
+ const { stale, reason } = mirrorStaleness(root, mirror);
361
+ if (stale) {
362
+ console.log(`board-mirror check FAILED: stale — ${reason}`);
363
+ process.exit(1);
364
+ }
365
+ console.log(`board-mirror ok: ${mirror.provider} mirror valid, not stale (requiresSync — drift not recomputable)`);
366
+ process.exit(0);
367
+ }
368
+
369
+ const recomputed = { ...mirror, links: provider.project(root), generatedAt: "" };
370
+ const onDisk = { ...mirror, generatedAt: "" };
371
+ const expected = serializeMirror(onDisk);
372
+ const actual = serializeMirror(recomputed);
373
+ if (expected !== actual) {
374
+ const strip = (l) => ({ id: l.id, status: l.status, specDir: l.specDir, acs: l.acs });
375
+ const byId = (arr) => Object.fromEntries(arr.map((l) => [l.id, l]));
376
+ const a = byId(onDisk.links || []);
377
+ const b = byId(recomputed.links);
378
+ const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
379
+ const drifted = [...ids].filter((id) => JSON.stringify(a[id] && strip(a[id])) !== JSON.stringify(b[id] && strip(b[id])));
380
+ console.log(`board-mirror check FAILED: mirror drifted from the recomputed "${mirror.provider}" projection`);
381
+ for (const id of drifted.sort(compareIds)) console.log(` - ${id}: on-disk mirror does not match the recomputed projection`);
382
+ process.exit(1);
383
+ }
384
+ console.log(`board-mirror ok: ${mirror.provider} mirror matches the recomputed projection (${recomputed.links.length} link(s))`);
385
+ process.exit(0);
386
+ }