@praxisflux/gates 0.57.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.
- package/codebase-to-course/lib/board-mirror.mjs +386 -0
- package/codebase-to-course/lib/spec-derive.mjs +3 -7
- package/codebase-to-course/lib/spec-source.mjs +108 -0
- package/grounding-wiki/lib/board-mirror.mjs +386 -0
- package/grounding-wiki/lib/spec-derive.mjs +3 -7
- package/grounding-wiki/lib/spec-source.mjs +108 -0
- package/lib/board-mirror.mjs +386 -0
- package/lib/spec-derive.mjs +3 -7
- package/lib/spec-source.mjs +108 -0
- package/package.json +1 -1
- package/spec-bridge/gates/bridge.mjs +6 -40
- package/spec-bridge/lib/board-mirror.mjs +386 -0
- package/spec-bridge/lib/spec-derive.mjs +3 -7
- package/spec-bridge/lib/spec-source.mjs +108 -0
|
@@ -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
|
+
}
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
25
25
|
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { resolveSpecSource } from "./spec-source.mjs";
|
|
29
28
|
|
|
30
29
|
export const STATUS = {
|
|
31
30
|
TODO: "To Do",
|
|
@@ -146,10 +145,7 @@ export function findCriticalFindings(markdown) {
|
|
|
146
145
|
* rather than crashing a sync or a Stop hook.
|
|
147
146
|
*/
|
|
148
147
|
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
149
|
-
const has
|
|
150
|
-
const read = (name) => {
|
|
151
|
-
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
152
|
-
};
|
|
148
|
+
const { has, read, source } = resolveSpecSource(specDir);
|
|
153
149
|
|
|
154
150
|
const tasksMd = read("tasks.md");
|
|
155
151
|
const phases = parseTasks(tasksMd);
|
|
@@ -180,7 +176,7 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
return {
|
|
183
|
-
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
|
|
179
|
+
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal, source,
|
|
184
180
|
// phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
|
|
185
181
|
// existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
|
|
186
182
|
phaseBoxes,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// spec-source.mjs — resolve a spec dir's has/read closures: working tree first, then git refs,
|
|
2
|
+
// so a spec dir that only lives on an unmerged task branch still derives its true state (spec
|
|
3
|
+
// 058) from a checkout that doesn't contain it.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: if the dir exists in the working tree, it wins unconditionally and git is never
|
|
6
|
+
// consulted (zero subprocess cost — the hot path, R2/R7). Otherwise the first ref among local
|
|
7
|
+
// HEAD then pushed `refs/remotes/origin/task-*` branches whose tree contains `<specDir>/spec.md`
|
|
8
|
+
// backs the closures via `git show <ref>:<path>`. No match anywhere, no git binary, or not a
|
|
9
|
+
// repo -> degrade to "nothing there", never throw (R5).
|
|
10
|
+
//
|
|
11
|
+
// Read-only plumbing only: show / rev-parse / for-each-ref. No fetch, no checkout, no index
|
|
12
|
+
// writes (R4). Ref enumeration and every (ref, path) read are memoized per repo root so a Stop
|
|
13
|
+
// hook re-deriving the same branch-held spec on every turn pays for git exactly once (R7).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute, dirname, sep } from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // git missing, not a repo, or the ref/path doesn't exist -> caller degrades
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk up from an (possibly nonexistent) absolute path to the nearest dir that actually exists,
|
|
28
|
+
// so `git rev-parse` has somewhere real to run from even when specDir itself is unmerged.
|
|
29
|
+
function nearestExistingDir(absPath) {
|
|
30
|
+
let dir = absPath;
|
|
31
|
+
while (!existsSync(dir)) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) return process.cwd();
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rootCache = new Map(); // startDir -> repo root, or null when not inside a repo
|
|
40
|
+
function repoRoot(startDir) {
|
|
41
|
+
if (!rootCache.has(startDir)) {
|
|
42
|
+
const out = git(["rev-parse", "--show-toplevel"], startDir);
|
|
43
|
+
rootCache.set(startDir, out ? out.trim() : null);
|
|
44
|
+
}
|
|
45
|
+
return rootCache.get(startDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const refsCache = new Map(); // repo root -> ["HEAD", ...pushed task branches], computed once
|
|
49
|
+
function listRefs(root) {
|
|
50
|
+
if (!refsCache.has(root)) {
|
|
51
|
+
const refs = ["HEAD"];
|
|
52
|
+
const out = git(["for-each-ref", "--format=%(refname)", "refs/remotes/origin/task-*"], root);
|
|
53
|
+
if (out) for (const line of out.split("\n")) { const ref = line.trim(); if (ref) refs.push(ref); }
|
|
54
|
+
refsCache.set(root, refs);
|
|
55
|
+
}
|
|
56
|
+
return refsCache.get(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showCache = new Map(); // `${root}\0${ref}\0${path}` -> file content, or null when absent
|
|
60
|
+
function showAt(root, ref, gitPath) {
|
|
61
|
+
const key = `${root}\0${ref}\0${gitPath}`;
|
|
62
|
+
if (!showCache.has(key)) showCache.set(key, git(["show", `${ref}:${gitPath}`], root));
|
|
63
|
+
return showCache.get(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toGitPath(root, absSpecDir) {
|
|
67
|
+
return relative(root, absSpecDir).split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NONE = { has: () => false, read: () => "", source: { kind: "none" } };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `{ has, read, source }` for one spec dir. `has(name)`/`read(name)` behave like the
|
|
74
|
+
* fs-backed closures they replace; `source` names where the answer came from:
|
|
75
|
+
* `{ kind: "worktree", path }`, `{ kind: "ref", ref }`, or `{ kind: "none" }`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveSpecSource(specDir) {
|
|
78
|
+
if (existsSync(specDir)) {
|
|
79
|
+
return {
|
|
80
|
+
has: (name) => existsSync(join(specDir, name)),
|
|
81
|
+
read: (name) => {
|
|
82
|
+
try { return existsSync(join(specDir, name)) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
83
|
+
},
|
|
84
|
+
source: { kind: "worktree", path: specDir },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve through symlinks (e.g. macOS's /var -> /private/var) so this path and git's own
|
|
89
|
+
// `--show-toplevel` output share one real filesystem root -- otherwise `relative()` below
|
|
90
|
+
// computes nonsense and no ref ever matches.
|
|
91
|
+
const absSpecDir = isAbsolute(specDir) ? specDir : join(process.cwd(), specDir);
|
|
92
|
+
const existingAncestor = nearestExistingDir(absSpecDir);
|
|
93
|
+
const suffix = relative(existingAncestor, absSpecDir);
|
|
94
|
+
const resolvedSpecDir = join(realpathSync(existingAncestor), suffix);
|
|
95
|
+
|
|
96
|
+
const root = repoRoot(realpathSync(existingAncestor));
|
|
97
|
+
if (!root) return NONE;
|
|
98
|
+
|
|
99
|
+
const gitPath = toGitPath(root, resolvedSpecDir);
|
|
100
|
+
const ref = listRefs(root).find((r) => showAt(root, r, `${gitPath}/spec.md`) !== null);
|
|
101
|
+
if (!ref) return NONE;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
has: (name) => showAt(root, ref, `${gitPath}/${name}`) !== null,
|
|
105
|
+
read: (name) => showAt(root, ref, `${gitPath}/${name}`) ?? "",
|
|
106
|
+
source: { kind: "ref", ref },
|
|
107
|
+
};
|
|
108
|
+
}
|