@isonimus/stele 0.1.2

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,463 @@
1
+ #!/usr/bin/env node
2
+ // Checks the document invariants defined in ADR-0003.
3
+ //
4
+ // Zero dependencies by design: this drops into any repo regardless of package manager.
5
+ // The frontmatter schema (ADR-0002) is a closed seven-field shape, small enough to parse
6
+ // by hand and not worth a YAML dependency.
7
+ //
8
+ // node scripts/lint-docs.mjs [repo-root ...] (default: cwd)
9
+ // --quiet only print problems
10
+ //
11
+ // Exit 1 if any error-severity rule fails. Warnings never fail the build.
12
+
13
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
14
+ import { join, basename } from 'node:path';
15
+
16
+ const STATUSES = ['accepted', 'proposed', 'superseded', 'amended'];
17
+ const TYPES = ['architecture', 'slice', 'batch'];
18
+ const REQUIRED = ['id', 'title', 'type', 'status', 'date'];
19
+ const DOC_DIRS = ['adr', 'slices'];
20
+
21
+ // The date the required-slice-section rules (R12/R13) shipped (ADR-0004, ADR-0011). A
22
+ // slice dated before this predates the rules and only warns; one dated on or after must
23
+ // comply. Without the split, a repo adopting this linter would go red on its whole legacy
24
+ // corpus (boxel's ~104 slices, gamatar's) the day it installs — but a rule that only ever
25
+ // warns never enforces the section on new work either. The date gates legacy in without
26
+ // letting new slices skip the contract.
27
+ const SLICE_SECTIONS_SINCE = '2026-07-22';
28
+
29
+ // --- frontmatter ------------------------------------------------------------
30
+
31
+ /** Ids are always 4-digit strings. Normalising early sidesteps YAML's octal reading of
32
+ * bare 0112 and makes ids safe as object keys. */
33
+ const normId = (v) => String(v).trim().padStart(4, '0');
34
+
35
+ const isId = (v) => /^\d{1,4}$/.test(String(v).trim());
36
+
37
+ // Citations, bare or qualified (ADR-0009). A leading `<repo>:` says the decision lives in
38
+ // another repo's corpus, which this linter cannot open and so must skip. The colon has to
39
+ // be adjacent, leaving an ordinary sentence ending in a colon ("see also: ADR-0004")
40
+ // resolving locally as before.
41
+ const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
42
+
43
+ /** Ids cited in `text` that this repo is expected to own — cross-repo refs skipped. */
44
+ function* localCitations(text) {
45
+ for (const [, repo, id] of text.matchAll(CITATION)) {
46
+ if (repo === undefined) yield normId(id);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Parses a flat scalar: quoted string, inline list, or bare value.
52
+ *
53
+ * A double-quoted value is decoded as a JSON string, because that is how the migrator
54
+ * emits free-text titles (`"Status effects: \"poison\"…"`). The two must share one
55
+ * escaping convention or a title with an inner quote round-trips wrong: migrated clean,
56
+ * misread at lint. Single-quoted values (ids) carry no escapes and only shed their quotes.
57
+ */
58
+ function parseScalar(raw) {
59
+ const v = raw.trim();
60
+ if (v.startsWith('[') && v.endsWith(']')) {
61
+ const inner = v.slice(1, -1).trim();
62
+ if (!inner) return [];
63
+ return inner.split(',').map((s) => parseScalar(s));
64
+ }
65
+ if (v.startsWith('"') && v.endsWith('"')) {
66
+ try {
67
+ return JSON.parse(v);
68
+ } catch {
69
+ return v.slice(1, -1);
70
+ }
71
+ }
72
+ return v.replace(/^'|'$/g, '');
73
+ }
74
+
75
+ /**
76
+ * Hand-rolled frontmatter reader for the ADR-0002 schema.
77
+ * Supports `key: value`, inline lists `[a, b]`, and block lists (`-` items).
78
+ * Returns { ok, data, body, error }.
79
+ */
80
+ export function parseFrontmatter(text) {
81
+ const lines = text.split('\n');
82
+ if (lines[0].trim() !== '---') {
83
+ return { ok: false, error: 'no frontmatter (file must open with ---)' };
84
+ }
85
+ const end = lines.indexOf('---', 1);
86
+ if (end === -1) return { ok: false, error: 'frontmatter is not terminated by ---' };
87
+
88
+ const data = {};
89
+ let currentKey = null;
90
+
91
+ for (let i = 1; i < end; i++) {
92
+ const line = lines[i];
93
+ if (!line.trim() || line.trim().startsWith('#')) continue;
94
+
95
+ const blockItem = line.match(/^\s*-\s+(.*)$/);
96
+ if (blockItem && currentKey) {
97
+ if (!Array.isArray(data[currentKey])) data[currentKey] = [];
98
+ data[currentKey].push(parseScalar(blockItem[1]));
99
+ continue;
100
+ }
101
+
102
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
103
+ if (!kv) return { ok: false, error: `unparseable frontmatter line ${i + 1}: "${line}"` };
104
+
105
+ const [, key, rest] = kv;
106
+ currentKey = key;
107
+ data[key] = rest.trim() === '' ? [] : parseScalar(rest);
108
+ }
109
+
110
+ return { ok: true, data, body: lines.slice(end + 1).join('\n') };
111
+ }
112
+
113
+ // --- loading ----------------------------------------------------------------
114
+
115
+ export function loadDocs(root) {
116
+ const dirs = DOC_DIRS.map((d) => join(root, d)).filter(existsSync);
117
+ const docs = [];
118
+
119
+ for (const dir of dirs) {
120
+ for (const file of readdirSync(dir).sort()) {
121
+ if (!file.endsWith('.md') || file === 'INDEX.md') continue;
122
+ const path = join(dir, file);
123
+ const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
124
+ docs.push({ path, file, kind: basename(dir), ...parsed });
125
+ }
126
+ }
127
+ return docs;
128
+ }
129
+
130
+ // --- section helpers --------------------------------------------------------
131
+ // Slice rules (R12/R13) assert the presence and shape of `## Sections` in the body prose.
132
+ // This is the only place the linter reads body text structurally; ADR-0002 keeps
133
+ // frontmatter the machine-readable surface, and a markdown heading is not frontmatter.
134
+
135
+ /** The text under a `## Heading`, up to the next `#`/`##` heading or end of body.
136
+ * Returns null when the heading is absent — distinct from a present-but-empty section. */
137
+ function sectionText(body, name) {
138
+ const heading = new RegExp(`^##\\s+${name}\\s*$`, 'i');
139
+ const lines = (body ?? '').split('\n');
140
+ const start = lines.findIndex((l) => heading.test(l.trim()));
141
+ if (start === -1) return null;
142
+ const rest = lines.slice(start + 1);
143
+ const end = rest.findIndex((l) => /^#{1,2}\s/.test(l.trim()));
144
+ return (end === -1 ? rest : rest.slice(0, end)).join('\n');
145
+ }
146
+
147
+ /** Which Gherkin step keywords appear as line-leading steps, tolerant of a leading list
148
+ * marker and markdown emphasis (`- **Given** …`). Shape only (ADR-0011): it reads that a
149
+ * step exists, never what the step claims. */
150
+ function gherkinSteps(text) {
151
+ const kinds = new Set();
152
+ for (const raw of text.split('\n')) {
153
+ const line = raw.trim().replace(/^[-*+>]\s*/, '').replace(/[*_`]/g, '');
154
+ const m = line.match(/^(?:and\s+|but\s+)?(given|when|then)\b/i);
155
+ if (m) kinds.add(m[1].toLowerCase());
156
+ }
157
+ return kinds;
158
+ }
159
+
160
+ /** A `## Definition of Done` has a real scenario when all three step kinds are present —
161
+ * a complete Given/When/Then triad's worth of steps, in any order. */
162
+ function hasGherkinTriad(text) {
163
+ const steps = gherkinSteps(text);
164
+ return steps.has('given') && steps.has('when') && steps.has('then');
165
+ }
166
+
167
+ // --- rules ------------------------------------------------------------------
168
+ // Each rule is (docs, root, report) => void. `report` takes (severity, path, message).
169
+ // Every rule traces to an observed failure; see the table in ADR-0003.
170
+
171
+ const rules = {
172
+ // R10 — a linter that finds nothing must not report success. Pointed at `boxel/adr`
173
+ // rather than the repo root, this printed "0 document(s) — ok" and exited 0: a hook
174
+ // wired to a wrong path would go green forever while checking nothing, which is the
175
+ // exact failure mode this file exists to prevent.
176
+ //
177
+ // Severity splits on *why* the corpus is empty. No adr/ or slices/ at all means the
178
+ // root is wrong — no repo using this method lacks both, so that is an error. Dirs that
179
+ // exist but hold no documents are a correctly-scaffolded repo that has not written its
180
+ // first ADR yet; erroring there would fail `npm run lint` during install, so it warns.
181
+ corpus(docs, root, report) {
182
+ if (docs.length > 0) return;
183
+ const present = DOC_DIRS.filter((d) => existsSync(join(root, d)));
184
+ if (present.length === 0) {
185
+ report('error', root, `R10 no ${DOC_DIRS.join('/ or ')}/ directory here — is this the repo root?`);
186
+ } else {
187
+ report('warn', root, `R10 ${present.map((d) => `${d}/`).join(' and ')} present but empty — no documents to check`);
188
+ }
189
+ },
190
+
191
+ // R1 — frontmatter present, parseable, required fields non-empty.
192
+ frontmatter(docs, _root, report) {
193
+ for (const d of docs) {
194
+ if (!d.ok) {
195
+ report('error', d.path, `R1 ${d.error}`);
196
+ continue;
197
+ }
198
+ for (const field of REQUIRED) {
199
+ const v = d.data[field];
200
+ if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) {
201
+ report('error', d.path, `R1 missing required field "${field}"`);
202
+ }
203
+ }
204
+ }
205
+ },
206
+
207
+ // R2 — id matches the filename ordinal; no duplicates. Slug is not a key:
208
+ // 0068-lava-fluid and 0128-lava-fluid share one, as do the three farming files.
209
+ ids(docs, _root, report) {
210
+ const seen = new Map();
211
+ for (const d of docs) {
212
+ if (!d.ok || d.data.id === undefined) continue;
213
+ const id = normId(d.data.id);
214
+
215
+ if (!isId(d.data.id)) {
216
+ report('error', d.path, `R2 id "${d.data.id}" is not a number`);
217
+ continue;
218
+ }
219
+ const fromName = d.file.match(/^(\d{1,4})/);
220
+ if (!fromName) {
221
+ report('error', d.path, `R2 filename does not start with an ordinal`);
222
+ } else if (normId(fromName[1]) !== id) {
223
+ report('error', d.path, `R2 id ${id} does not match filename ordinal ${normId(fromName[1])}`);
224
+ }
225
+
226
+ if (seen.has(id)) {
227
+ report('error', d.path, `R2 duplicate id ${id} (also in ${basename(seen.get(id))})`);
228
+ } else {
229
+ seen.set(id, d.path);
230
+ }
231
+ }
232
+ },
233
+
234
+ // R3 — closed vocabularies. Legacy carried Implemented/ACCEPTED/**Accepted**.
235
+ vocabulary(docs, _root, report) {
236
+ for (const d of docs) {
237
+ if (!d.ok) continue;
238
+ const { status, type } = d.data;
239
+ if (status !== undefined && !STATUSES.includes(status)) {
240
+ report('error', d.path, `R3 status "${status}" not in [${STATUSES.join(', ')}]`);
241
+ }
242
+ if (type !== undefined && !TYPES.includes(type)) {
243
+ report('error', d.path, `R3 type "${type}" not in [${TYPES.join(', ')}]`);
244
+ }
245
+ }
246
+ },
247
+
248
+ // R4/R5/R6/R7 — the supersession graph. These share an index, so they run together.
249
+ supersession(docs, _root, report) {
250
+ const byId = new Map();
251
+ for (const d of docs) {
252
+ if (d.ok && d.data.id !== undefined && isId(d.data.id)) byId.set(normId(d.data.id), d);
253
+ }
254
+ const listOf = (d, key) => {
255
+ const v = d.data[key];
256
+ if (v === undefined) return [];
257
+ return (Array.isArray(v) ? v : [v]).filter((x) => x !== '').map(normId);
258
+ };
259
+
260
+ for (const [id, d] of byId) {
261
+ const supersededBy = listOf(d, 'superseded_by');
262
+ const supersedes = listOf(d, 'supersedes');
263
+
264
+ // R5 — dangling references. Legacy 0051 and 0061 claimed supersession with no
265
+ // resolvable target at all.
266
+ for (const key of ['supersedes', 'superseded_by']) {
267
+ for (const ref of listOf(d, key)) {
268
+ if (!byId.has(ref)) report('error', d.path, `R5 ${key} references ADR ${ref}, which does not exist`);
269
+ }
270
+ }
271
+
272
+ // R4 — bidirectionality. This is the 0112/0113/0114 -> 0122 defect: each declared
273
+ // itself superseded, and 0122 acknowledged none of them.
274
+ for (const ref of supersededBy) {
275
+ const target = byId.get(ref);
276
+ if (target && !listOf(target, 'supersedes').includes(id)) {
277
+ report('error', d.path, `R4 declares superseded_by ${ref}, but ADR ${ref} does not list ${id} in supersedes`);
278
+ }
279
+ }
280
+ for (const ref of supersedes) {
281
+ const target = byId.get(ref);
282
+ if (target && !listOf(target, 'superseded_by').includes(id)) {
283
+ report('error', d.path, `R4 declares it supersedes ${ref}, but ADR ${ref} does not list ${id} in superseded_by`);
284
+ }
285
+ }
286
+
287
+ // R6 — status and supersession must agree. Legacy 0112/0114 read
288
+ // "Status: Accepted" four lines above "Superseded by ADR 0122".
289
+ const isSuperseded = d.data.status === 'superseded';
290
+ if (isSuperseded && supersededBy.length === 0) {
291
+ report('error', d.path, `R6 status is superseded but superseded_by is empty`);
292
+ }
293
+ if (!isSuperseded && supersededBy.length > 0) {
294
+ report('error', d.path, `R6 superseded_by names ${supersededBy.join(', ')} but status is "${d.data.status}"`);
295
+ }
296
+ }
297
+
298
+ // R7 — the same disagreement seen from the other side: B claims to supersede A while
299
+ // A still reads as live. R6 cannot catch this when A says nothing at all.
300
+ for (const [id, d] of byId) {
301
+ for (const ref of listOf(d, 'supersedes')) {
302
+ const target = byId.get(ref);
303
+ if (target && target.data.status === 'accepted') {
304
+ report('error', target.path, `R7 is "accepted" but ADR ${id} claims to supersede it`);
305
+ }
306
+ }
307
+ }
308
+ },
309
+
310
+ // R8 — ledger citations resolve. The ledger is the only mutable file (ADR-0001);
311
+ // if it cites a decision, that decision must exist.
312
+ ledger(docs, root, report) {
313
+ const path = join(root, 'LEDGER.md');
314
+ if (!existsSync(path)) return;
315
+ const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
316
+
317
+ const text = readFileSync(path, 'utf8');
318
+ text.split('\n').forEach((line, i) => {
319
+ for (const id of localCitations(line)) {
320
+ if (!ids.has(id)) {
321
+ report('error', path, `R8 line ${i + 1} cites ADR ${id}, which does not exist. Another repo's decision is cited as \`<repo>:ADR-${id}\` (ADR-0009).`);
322
+ }
323
+ }
324
+ });
325
+ },
326
+
327
+ // R11 — every verify script is wired into package.json (ADR-0004). The harness's
328
+ // load-bearing half: an unwired `*-verify.mjs` ran once on the day it was written and
329
+ // never again — ADR-0004 Finding 2 found eleven of twelve boxel scripts in exactly that
330
+ // state. This is the first *harness* rule; R1–R9 (and R10) check documents. It reads
331
+ // scripts/ and package.json, never CLAUDE.md, so no prose enters the checked surface.
332
+ //
333
+ // Probes are excluded by name: a probe answers a design question once and its number
334
+ // goes in an ADR, so it is not a standing regression and is not required to be wired.
335
+ harnessWiring(_docs, root, report) {
336
+ const scriptsDir = join(root, 'scripts');
337
+ const pkgPath = join(root, 'package.json');
338
+ if (!existsSync(scriptsDir) || !existsSync(pkgPath)) return;
339
+
340
+ let pkg;
341
+ try {
342
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
343
+ } catch (err) {
344
+ report('error', pkgPath, `R11 package.json is not valid JSON: ${err.message}`);
345
+ return;
346
+ }
347
+
348
+ // A script is wired if its filename is the basename of a token in any npm command.
349
+ // Tokenising and comparing basenames — rather than a substring test — is what keeps
350
+ // `a-verify.mjs` from matching a runner that only mentions `xa-verify.mjs`, and lets
351
+ // one aggregate command (`node scripts/a.mjs && node scripts/b.mjs`) wire both.
352
+ const wired = new Set(
353
+ Object.values(pkg.scripts ?? {})
354
+ .flatMap((cmd) => String(cmd).split(/[\s'"]+/))
355
+ .map((tok) => basename(tok)),
356
+ );
357
+
358
+ for (const file of readdirSync(scriptsDir).sort()) {
359
+ if (!/-verify\.(mjs|mts)$/.test(file)) continue;
360
+ if (!wired.has(file)) {
361
+ report('error', join(scriptsDir, file), `R11 ${file} is not wired into package.json — it would run never`);
362
+ }
363
+ }
364
+ },
365
+
366
+ // R12/R13 — required slice sections. A slice is a feature work-unit (ADR-0001); two
367
+ // sections complete its contract: `## Verification` names the proof (ADR-0004), and
368
+ // `## Definition of Done` states the acceptance criteria in Given/When/Then (ADR-0011).
369
+ // Both requirements predated any check — ADR-0004 named the Verification section but
370
+ // nothing enforced it, the same gap R11 closed for wiring. They are checked together
371
+ // because they share one severity rule.
372
+ //
373
+ // Severity splits on the slice's own date (SLICE_SECTIONS_SINCE): a slice predating the
374
+ // rules only warns, so a repo's legacy corpus does not go red on adoption; one dated on
375
+ // or after must comply, so new work is enforced rather than merely nagged. Presence and
376
+ // triad-shape are all this checks — whether a scenario is correct or the set complete is
377
+ // the coverage question, left to `/wrap-up` (ADR-0004, ADR-0011).
378
+ sliceSections(docs, _root, report) {
379
+ for (const d of docs) {
380
+ if (!d.ok || d.data.type !== 'slice') continue;
381
+ const severity = String(d.data.date ?? '') >= SLICE_SECTIONS_SINCE ? 'error' : 'warn';
382
+
383
+ if (sectionText(d.body, 'Verification') === null) {
384
+ report(severity, d.path, `R12 type: slice has no "## Verification" section (ADR-0004)`);
385
+ }
386
+
387
+ const dod = sectionText(d.body, 'Definition of Done');
388
+ if (dod === null) {
389
+ report(severity, d.path, `R13 type: slice has no "## Definition of Done" section (ADR-0011)`);
390
+ } else if (!hasGherkinTriad(dod)) {
391
+ report(severity, d.path, `R13 "## Definition of Done" has no Given/When/Then scenario (ADR-0011)`);
392
+ }
393
+ }
394
+ },
395
+
396
+ // R9 — prose cross-references. Warning only, deliberately: boxel carries 567 bare
397
+ // references, some pointing at external or historical context. Failing the build on
398
+ // those would make the linter something to disable rather than obey.
399
+ //
400
+ // Since ADR-0009 a bare reference means unambiguously "in this repo" — the other-repo
401
+ // case has its own syntax — so the remaining obstacle to erroring here is boxel's
402
+ // legacy volume alone, not the mechanism.
403
+ proseRefs(docs, _root, report) {
404
+ const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
405
+ for (const d of docs) {
406
+ if (!d.ok || !d.body) continue;
407
+ const unresolved = new Set();
408
+ for (const id of localCitations(d.body)) {
409
+ if (!ids.has(id)) unresolved.add(id);
410
+ }
411
+ for (const ref of [...unresolved].sort()) {
412
+ report('warn', d.path, `R9 prose references ADR ${ref}, which does not exist`);
413
+ }
414
+ }
415
+ },
416
+ };
417
+
418
+ // --- runner -----------------------------------------------------------------
419
+
420
+ export function lint(root) {
421
+ const docs = loadDocs(root);
422
+ const findings = [];
423
+ const report = (severity, path, message) => findings.push({ severity, path, message });
424
+ for (const rule of Object.values(rules)) rule(docs, root, report);
425
+ return { docs, findings };
426
+ }
427
+
428
+ function main(argv) {
429
+ const quiet = argv.includes('--quiet');
430
+ const roots = argv.filter((a) => !a.startsWith('--'));
431
+ if (roots.length === 0) roots.push(process.cwd());
432
+
433
+ let errors = 0;
434
+ let warnings = 0;
435
+
436
+ for (const root of roots) {
437
+ const { docs, findings } = lint(root);
438
+ const errs = findings.filter((f) => f.severity === 'error');
439
+ const warns = findings.filter((f) => f.severity === 'warn');
440
+ errors += errs.length;
441
+ warnings += warns.length;
442
+
443
+ if (!quiet || findings.length) {
444
+ console.log(`\n${root} — ${docs.length} document(s)`);
445
+ }
446
+ for (const f of [...errs, ...warns]) {
447
+ const tag = f.severity === 'error' ? 'ERROR' : ' WARN';
448
+ // Corpus-level findings are reported against the root itself; basename would render
449
+ // it as "adr: no adr/ directory here", which reads as a contradiction.
450
+ const where = f.path === root ? root : basename(f.path);
451
+ console.log(` ${tag} ${where}: ${f.message}`);
452
+ }
453
+ if (!findings.length && !quiet) console.log(' ok');
454
+ }
455
+
456
+ const summary = `\n${errors} error(s), ${warnings} warning(s)`;
457
+ if (!quiet || errors || warnings) console.log(summary);
458
+ return errors > 0 ? 1 : 0;
459
+ }
460
+
461
+ if (import.meta.url === `file://${process.argv[1]}`) {
462
+ process.exit(main(process.argv.slice(2)));
463
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // Prepends ADR-0002 frontmatter to a legacy corpus, using facts recovered by
3
+ // scan-legacy.mjs. Dry-run by default (ADR-0003's migration plan).
4
+ //
5
+ // node scripts/migrate-adrs.mjs <repo-root> [--apply] [--verbose]
6
+ //
7
+ // Three guarantees, because this rewrites 130 files that are historical records:
8
+ //
9
+ // 1. Prose is never touched. The only write is a frontmatter block prepended to the
10
+ // file. The legacy `## Status` section stays exactly where it is — it is part of
11
+ // the historical claim, and duplicating a fact into frontmatter does not license
12
+ // deleting the original.
13
+ // 2. Idempotent. A file that already opens with `---` is reported as `skip` and left
14
+ // alone, so a partial run can be resumed and a full re-run is a zero diff.
15
+ // 3. Drift is migrated faithfully, not silently repaired. boxel 0112 says "superseded
16
+ // by 0122" while its status reads Accepted; the migration writes exactly that, and
17
+ // the linter then reports it as the R6 violation it has been for weeks. Papering
18
+ // over it here would destroy the evidence and leave the linter unvalidated.
19
+
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { execFileSync } from 'node:child_process';
23
+
24
+ import { scanLegacy, findDefects } from './scan-legacy.mjs';
25
+
26
+ /**
27
+ * The 22 architectural ids from boxel's corpus survey: decisions later work must obey
28
+ * (a mechanism, data format, or boundary). Everything else is a feature work-unit.
29
+ *
30
+ * Seeded from a hand survey rather than inferred. There is no textual signal that
31
+ * separates the two kinds — that absence is the reason ADR-0002 exists — so a heuristic
32
+ * here would be a guess wearing a script's authority.
33
+ *
34
+ * This is boxel's set, and it is only a default. The architectural set is per-repo — a
35
+ * fact the deferred `/init-method` survey step exists to capture — so any other corpus
36
+ * must pass its own via the `architecture` option (CLI: `--arch=0001,0002,...`).
37
+ */
38
+ const DEFAULT_ARCHITECTURE = new Set(
39
+ ['0001', '0002', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012',
40
+ '0013', '0014', '0021', '0025', '0036', '0041', '0051', '0054', '0057', '0068',
41
+ '0122', '0123'],
42
+ );
43
+
44
+ /** Bundles several unrelated features and cannot map 1:1 to a work item (LEDGER audit). */
45
+ const isBatch = (record) => /\bbatch\b/i.test(record.title);
46
+
47
+ const ISO_DATE = /\b(20\d{2}-\d{2}-\d{2})\b/;
48
+
49
+ /**
50
+ * Recovers the decision date.
51
+ *
52
+ * Preference order matters. The status blob's own date is the author's claim about when
53
+ * the decision was made; the git date only says when the file landed. For design-first
54
+ * ADRs those differ, and the document's own account wins.
55
+ *
56
+ * The exception is a header that records a *transition*. `Status: superseded (2026-07-20)`
57
+ * dates the supersession, not the decision — reading it as the decision date backdates the
58
+ * ADR to the day it died. Those headers carry no decision date at all, so git's add date is
59
+ * the only honest source.
60
+ */
61
+ const TRANSITION_STATUS = new Set(['superseded', 'amended']);
62
+
63
+ function resolveDate(root, record, text) {
64
+ const header = text.split('\n').slice(0, 12).join('\n');
65
+ const stated = header.match(ISO_DATE);
66
+ if (stated && !TRANSITION_STATUS.has(record.status)) {
67
+ return { date: stated[1], source: 'stated' };
68
+ }
69
+
70
+ try {
71
+ const out = execFileSync(
72
+ 'git',
73
+ ['log', '--diff-filter=A', '--format=%ad', '--date=short', '--', join('adr', record.file)],
74
+ { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
75
+ ).trim();
76
+ const first = out.split('\n').filter(Boolean).pop();
77
+ if (first) return { date: first, source: 'git' };
78
+ } catch {
79
+ // Not a git repo, or the file is untracked. Fall through.
80
+ }
81
+
82
+ // A transition header's date is imprecise, but it beats blocking the migration on a
83
+ // file git has never seen.
84
+ if (stated) return { date: stated[1], source: 'transition' };
85
+
86
+ return { date: null, source: 'none' };
87
+ }
88
+
89
+ /** Serialises the closed ADR-0002 schema. Ids stay quoted so `0112` survives as a string. */
90
+ function renderFrontmatter(fields) {
91
+ const list = (ids) => `[${ids.map((i) => `'${i}'`).join(', ')}]`;
92
+ return [
93
+ '---',
94
+ `id: '${fields.id}'`,
95
+ `title: ${JSON.stringify(fields.title)}`,
96
+ `type: ${fields.type}`,
97
+ `status: ${fields.status}`,
98
+ `date: ${fields.date}`,
99
+ `supersedes: ${list(fields.supersedes)}`,
100
+ `superseded_by: ${list(fields.superseded_by)}`,
101
+ '---',
102
+ '',
103
+ '',
104
+ ].join('\n');
105
+ }
106
+
107
+ export function planMigration(root, { architecture = DEFAULT_ARCHITECTURE } = {}) {
108
+ const records = scanLegacy(root);
109
+
110
+ return records.map((record) => {
111
+ const path = join(root, 'adr', record.file);
112
+ const text = readFileSync(path, 'utf8');
113
+
114
+ if (text.startsWith('---\n')) {
115
+ return { record, path, action: 'skip', reason: 'already has frontmatter' };
116
+ }
117
+ if (!record.status) {
118
+ return { record, path, action: 'block', reason: `unmapped status "${record.legacyStatus}"` };
119
+ }
120
+
121
+ const { date, source } = resolveDate(root, record, text);
122
+ if (!date) {
123
+ return { record, path, action: 'block', reason: 'no date in header and none in git' };
124
+ }
125
+
126
+ const fields = {
127
+ id: record.id,
128
+ title: record.title,
129
+ type: architecture.has(record.id) ? 'architecture' : isBatch(record) ? 'batch' : 'slice',
130
+ status: record.status,
131
+ date,
132
+ supersedes: record.supersedes.sort(),
133
+ superseded_by: record.supersededBy.sort(),
134
+ };
135
+
136
+ return { record, path, action: 'write', dateSource: source, fields,
137
+ content: renderFrontmatter(fields) + text };
138
+ });
139
+ }
140
+
141
+ function report(root, plan, verbose) {
142
+ const counts = plan.reduce((a, p) => ({ ...a, [p.action]: (a[p.action] ?? 0) + 1 }), {});
143
+ const writes = plan.filter((p) => p.action === 'write');
144
+
145
+ const tally = (key) =>
146
+ writes.reduce((a, p) => ({ ...a, [p.fields[key]]: (a[p.fields[key]] ?? 0) + 1 }), {});
147
+
148
+ console.log(`${plan.length} ADR(s) in ${root}`);
149
+ console.log('actions:', counts);
150
+ console.log('type: ', tally('type'));
151
+ console.log('status: ', tally('status'));
152
+ console.log('date: ', writes.reduce((a, p) => ({ ...a, [p.dateSource]: (a[p.dateSource] ?? 0) + 1 }), {}));
153
+
154
+ const blocked = plan.filter((p) => p.action === 'block');
155
+ if (blocked.length) {
156
+ console.log(`\n${blocked.length} blocked:`);
157
+ for (const p of blocked) console.log(` ${p.record.id} ${p.reason}`);
158
+ }
159
+
160
+ // Supersession is the one field a reviewer must check by eye: it is recovered from
161
+ // prose, and a wrong link marks a live decision dead.
162
+ const linked = writes.filter((p) => p.fields.supersedes.length || p.fields.superseded_by.length);
163
+ console.log(`\n${linked.length} with supersession links:`);
164
+ for (const p of linked) {
165
+ const f = p.fields;
166
+ const arrows = [
167
+ f.supersedes.length ? `supersedes ${f.supersedes.join(',')}` : '',
168
+ f.superseded_by.length ? `superseded_by ${f.superseded_by.join(',')}` : '',
169
+ ].filter(Boolean).join(' ');
170
+ console.log(` ${f.id} [${f.status}] ${arrows}`);
171
+ }
172
+
173
+ const defects = findDefects(plan.map((p) => p.record));
174
+ console.log(`\n${defects.length} pre-existing defect(s), migrated as-is for the linter to catch:`);
175
+ for (const d of defects) {
176
+ console.log(` ${d.kind.padEnd(16)} ${d.id}${d.target ? ` -> ${d.target}` : ` line ${d.line}`}`);
177
+ }
178
+
179
+ if (verbose) {
180
+ console.log('\nfrontmatter to be written:\n');
181
+ for (const p of writes) console.log(`--- ${p.record.file}\n${renderFrontmatter(p.fields)}`);
182
+ }
183
+ }
184
+
185
+ function main(argv) {
186
+ const root = argv.find((a) => !a.startsWith('--'));
187
+ if (!root) {
188
+ console.error('usage: migrate-adrs.mjs <repo-root> [--apply] [--verbose] [--arch=0001,0002,...]');
189
+ return 2;
190
+ }
191
+
192
+ const archArg = argv.find((a) => a.startsWith('--arch='));
193
+ const architecture = archArg
194
+ ? new Set(archArg.slice('--arch='.length).split(',').map((s) => s.trim()).filter(Boolean))
195
+ : undefined;
196
+
197
+ const plan = planMigration(root, { architecture });
198
+ report(root, plan, argv.includes('--verbose'));
199
+
200
+ const blocked = plan.filter((p) => p.action === 'block');
201
+ if (!argv.includes('--apply')) {
202
+ console.log(`\nDRY RUN — nothing written. Re-run with --apply.`);
203
+ return 0;
204
+ }
205
+ if (blocked.length) {
206
+ console.error(`\nRefusing to apply: ${blocked.length} file(s) blocked. Resolve them first.`);
207
+ return 1;
208
+ }
209
+
210
+ const writes = plan.filter((p) => p.action === 'write');
211
+ for (const p of writes) writeFileSync(p.path, p.content);
212
+ console.log(`\nWrote frontmatter to ${writes.length} file(s).`);
213
+ return 0;
214
+ }
215
+
216
+ if (import.meta.url === `file://${process.argv[1]}`) {
217
+ process.exit(main(process.argv.slice(2)));
218
+ }