@sabaiway/agent-workflow-kit 4.4.0 → 5.0.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,466 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { tokenizeMarkdown } from './markdown-blocks.mjs';
4
+ import {
5
+ parseChangelogText,
6
+ computeCutoffs,
7
+ categorize,
8
+ compressEntry,
9
+ buildChangelog,
10
+ buildRecent,
11
+ buildCold,
12
+ groupByMonth,
13
+ } from './archive-changelog.mjs';
14
+
15
+ // ── The conservation / round-trip harness (Phase 2.0) ─────────────────────────────────
16
+ //
17
+ // Built BEFORE the parsers move onto the tokenizer, against the current code, so it is a
18
+ // checker and not a rationalisation. Its contract, for EVERY input:
19
+ //
20
+ // either the parse REFUSES loudly (a typed Error carrying exitCode 1 and a message),
21
+ // or the three properties hold:
22
+ // conservation — every body line lands in exactly one bucket, none dropped, none doubled
23
+ // round-trip — a full rotation re-parses to the same entry set
24
+ // self-consumption — the rotator accepts the HOT/WARM/COLD its own writer just wrote
25
+ //
26
+ // A crash (TypeError, missing exitCode) is NOT a refusal: a refusal has a predicate AND a
27
+ // disposition, and the disposition asserted here is "a typed error that names its cause,
28
+ // with no partial result". Deliberately dropped decoration (blank lines, `---` separators,
29
+ // `**Last Updated:**` footers) is excluded from the accounting on both sides — everything
30
+ // else must be conserved.
31
+
32
+ const FM = '---\ntype: history\nlastUpdated: 2026-07-28\nmaxLines: 700\n---\n';
33
+ const B = '```';
34
+ const TODAY = '2026-07-28';
35
+
36
+ const matchableView = (line) => line.replace(/\s+$/, '');
37
+ const isDroppable = (line) =>
38
+ line === '' || line === '---' || /^\*\*Last Updated:/i.test(line);
39
+
40
+ const accountable = (text) =>
41
+ text.split('\n').map(matchableView).filter((line) => !isDroppable(line));
42
+
43
+ const countLines = (lines) => {
44
+ const counts = new Map();
45
+ for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1);
46
+ return counts;
47
+ };
48
+
49
+ const conservationDelta = (text, parsed) => {
50
+ const input = countLines(accountable(text));
51
+ const output = countLines(
52
+ [parsed.frontmatter, parsed.preamble, ...parsed.entries.map((e) => e.block), parsed.footer]
53
+ .filter((s) => s !== '')
54
+ .flatMap((s) => accountable(s)),
55
+ );
56
+ const missing = [];
57
+ const duplicated = [];
58
+ for (const [line, n] of input) if ((output.get(line) ?? 0) < n) missing.push(line);
59
+ for (const [line, n] of output) if (n > (input.get(line) ?? 0)) duplicated.push(line);
60
+ return { missing, duplicated };
61
+ };
62
+
63
+ const assertTypedRefusal = (err, context) => {
64
+ assert.ok(err instanceof Error, `${context}: a refusal must be an Error, got ${typeof err}`);
65
+ assert.equal(
66
+ err.exitCode,
67
+ 1,
68
+ `${context}: a refusal carries exitCode 1 — a crash is not a refusal (${err.message})`,
69
+ );
70
+ assert.ok(err.message.length > 0, `${context}: a refusal names its cause`);
71
+ };
72
+
73
+ const identity = (entry) => `${entry.dateStr}|${entry.title}`;
74
+
75
+ const rotateInMemory = (parsed) => {
76
+ const cutoffs = computeCutoffs(TODAY, 3, 30);
77
+ const { hot, warm, cold } = categorize(parsed.entries, cutoffs);
78
+ const coldByMonth = groupByMonth(cold);
79
+ const tiers = [
80
+ [
81
+ 'HOT',
82
+ buildChangelog({
83
+ frontmatter: parsed.frontmatter || FM,
84
+ preamble: parsed.preamble || '# Changelog',
85
+ hot,
86
+ footer: parsed.footer,
87
+ hasArchive: warm.length > 0 || cold.length > 0,
88
+ }),
89
+ ],
90
+ ];
91
+ if (warm.length > 0) tiers.push(['WARM', buildRecent(warm, TODAY)]);
92
+ for (const [key, entries] of coldByMonth) {
93
+ const [year, month] = key.split('-');
94
+ tiers.push([`COLD ${key}`, buildCold(year, month, entries, TODAY)]);
95
+ }
96
+ return { tiers, hot, warm, cold };
97
+ };
98
+
99
+ // ── seeded generator with a one-pass oracle ───────────────────────────────────────────
100
+ //
101
+ // The oracle (which headings are REAL entries, and whether the document must refuse) is
102
+ // recorded WHILE emitting, never re-derived with a regex afterwards — so a disagreement
103
+ // with the parser is a real failure, not two copies of one mistake agreeing. Drawn from
104
+ // the LCG's high bits (the low bit strictly alternates and starves input classes).
105
+
106
+ const DATE_GRID = [
107
+ ['07', '28'], ['07', '27'], ['07', '26'], // HOT at TODAY with hot-days=3
108
+ ['07', '20'], ['07', '10'], ['07', '02'], // WARM
109
+ ['05', '27'], ['05', '15'], ['03', '02'], // COLD
110
+ ];
111
+
112
+ const MALFORMED = [
113
+ '## 2026-06-15 (no dash sep)',
114
+ '## 2026/07/20 — slash date',
115
+ '## 2026-7-20 — single digit',
116
+ '## 2026-07 — truncated',
117
+ '## 20260720 — unseparated',
118
+ '## 2026-07.20 — mixed separators',
119
+ '## 2026-02-30 — impossible date',
120
+ ' ## 2026-07-19 — indented',
121
+ '##\t2026-07-18 — tab separated',
122
+ ];
123
+
124
+ const buildDoc = (seed) => {
125
+ let state = seed;
126
+ const rand = (n) => {
127
+ state = (state * 1103515245 + 12345) % 2147483648;
128
+ return Math.floor(state / 65536) % n;
129
+ };
130
+
131
+ const eol = rand(4) === 0 ? '\r\n' : '\n';
132
+ const rows = [];
133
+ const oracle = { entries: [], mustRefuse: false, crlf: eol === '\r\n' };
134
+
135
+ const frontmatter = rand(5) === 0 ? '' : FM.replace(/\n/g, eol);
136
+ rows.push('# Changelog');
137
+ rows.push('');
138
+ if (rand(3) === 0) {
139
+ rows.push('## History');
140
+ rows.push('');
141
+ rows.push('> older sessions are layered.');
142
+ rows.push('');
143
+ rows.push('---');
144
+ rows.push('');
145
+ }
146
+
147
+ const entryCount = 1 + rand(4);
148
+ // start ranges over the WHOLE grid tail (a start of 3 was the blind spot: with at most four
149
+ // entries the COLD rows were unreachable in every seed, and the non-idempotent COLD compressor
150
+ // sailed through — the tier sentinel below pins the reach).
151
+ const start = rand(6);
152
+ for (let i = 0; i < entryCount; i += 1) {
153
+ const [month, day] = DATE_GRID[start + i];
154
+ const sep = rand(2) === 0 ? '.' : '-';
155
+ const title = rand(5) === 0 ? '' : `entry ${seed}-${i}`;
156
+ rows.push(title === '' ? `## 2026${sep}${month}${sep}${day}` : `## 2026${sep}${month}${sep}${day} — ${title}`);
157
+ oracle.entries.push(`2026.${month}.${day}|${title}`);
158
+ rows.push('');
159
+
160
+ const bodyKind = rand(4);
161
+ if (bodyKind === 0) {
162
+ // Sometimes metric-bearing, so COLD compression's **Result:** extraction is exercised.
163
+ rows.push(rand(2) === 0 ? `**Goal:** body of ${i}.` : `**Goal:** body of ${i}; ${1 + rand(20)} tests green.`);
164
+ } else if (bodyKind === 1) {
165
+ const fence = rand(2) === 0 ? B : '~~~';
166
+ const lead = rand(2) === 0;
167
+ if (lead) {
168
+ rows.push('Write entries like this:');
169
+ rows.push('');
170
+ }
171
+ rows.push(`${fence}markdown`);
172
+ rows.push(`## 2026.01.0${1 + rand(8)} — fenced sample`);
173
+ if (rand(2) === 0) {
174
+ rows.push('');
175
+ rows.push('more fenced text');
176
+ }
177
+ rows.push(fence);
178
+ } else {
179
+ rows.push(`plain body ${i}.`);
180
+ rows.push(`second line ${i}.`);
181
+ }
182
+ rows.push('');
183
+ if (rand(3) === 0) {
184
+ rows.push('---');
185
+ rows.push('');
186
+ }
187
+ if (rand(4) === 0) {
188
+ rows.push(MALFORMED[rand(MALFORMED.length)]);
189
+ rows.push('');
190
+ rows.push('ORPHAN BODY.');
191
+ rows.push('');
192
+ oracle.mustRefuse = true;
193
+ }
194
+ }
195
+
196
+ if (rand(3) === 0) {
197
+ rows.push('## Footer');
198
+ rows.push('');
199
+ rows.push('stray note.');
200
+ }
201
+ if (rand(6) === 0) {
202
+ rows.push(`${B}markdown`);
203
+ rows.push('## 2026.02.02 — hidden behind the open fence');
204
+ oracle.mustRefuse = true;
205
+ }
206
+
207
+ return { text: frontmatter + rows.join(eol) + eol, ...oracle };
208
+ };
209
+
210
+ // ── calibration — green before and after, recorded with the baseline ──────────────────
211
+
212
+ describe('calibration — a clean corpus, both separators', () => {
213
+ it('parses, conserves every line, and rotation re-parses to the same set', () => {
214
+ const text = `${FM}\n# Changelog\n\n## 2026-07-28 — iso hot\n\nbody a.\n\n## 2026.07.10 — dotted warm\n\nbody b.\n\n## 2026.03.02 — dotted cold\n\nbody c.\n\n## Footer\n\nstray.\n`;
215
+ const parsed = parseChangelogText(text);
216
+ assert.deepEqual(parsed.entries.map(identity), [
217
+ '2026.07.28|iso hot',
218
+ '2026.07.10|dotted warm',
219
+ '2026.03.02|dotted cold',
220
+ ]);
221
+ assert.deepEqual(conservationDelta(text, parsed), { missing: [], duplicated: [] });
222
+ const { tiers } = rotateInMemory(parsed);
223
+ const reparsed = tiers.flatMap(([, tierText]) => parseChangelogText(tierText).entries.map(identity));
224
+ assert.deepEqual(reparsed.sort(), parsed.entries.map(identity).sort());
225
+ });
226
+ });
227
+
228
+ // ── doorway fixtures — one per face observed live in Phase 1 ──────────────────────────
229
+
230
+ describe('doorway fixtures — each must refuse or conserve', () => {
231
+ const between = (middle) =>
232
+ `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\nbody one.\n\n${middle}\n\nORPHAN BODY.\n\n## 2026.01.05 — good two\n\nbody two.\n`;
233
+
234
+ it('doorway one: a malformed date heading between entries is never glued or duplicated', () => {
235
+ const text = between('## 2026-06-15 (no dash sep)');
236
+ let parsed;
237
+ try {
238
+ parsed = parseChangelogText(text);
239
+ } catch (err) {
240
+ assertTypedRefusal(err, 'doorway one');
241
+ return;
242
+ }
243
+ assert.deepEqual(conservationDelta(text, parsed), { missing: [], duplicated: [] });
244
+ });
245
+
246
+ it('doorway two: a heading inside a closed fence is never counted as an entry', () => {
247
+ const text = `${FM}\n# Changelog\n\n## 2026.07.21 — teaches the format\n\nWrite entries like this:\n\n${B}markdown\n## 2026.01.03 — fenced sample\n${B}\n\nend of body.\n`;
248
+ const parsed = parseChangelogText(text);
249
+ assert.deepEqual(parsed.entries.map(identity), ['2026.07.21|teaches the format']);
250
+ });
251
+
252
+ it('doorway three: an unclosed fence refuses loudly instead of hiding the rest of the file', () => {
253
+ const text = `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\n${B}markdown\n## 2026.07.20 — hidden\n`;
254
+ let threw = null;
255
+ try {
256
+ parseChangelogText(text);
257
+ } catch (err) {
258
+ threw = err;
259
+ }
260
+ assert.ok(threw, 'an unclosed fence was silently absorbed');
261
+ assertTypedRefusal(threw, 'doorway three');
262
+ });
263
+
264
+ it('doorway four: the writer never emits an unclosed fence — compressed output tokenizes', () => {
265
+ const text = `${FM}\n# Changelog\n\n## 2026.03.02 — carries a fenced block\n\n${B}\nfenced text\n\nmore fenced text\n${B}\n`;
266
+ const parsed = parseChangelogText(text);
267
+ assert.equal(parsed.entries.length, 1);
268
+ const cold = buildCold('2026', '03', parsed.entries, TODAY);
269
+ tokenizeMarkdown(cold, 'history/2026-03.md');
270
+ });
271
+
272
+ it('doorway five: a CRLF file parses identically to its LF twin', () => {
273
+ const lf = `${FM}\n# Changelog\n\n## 2026.07.21 — titled\n\nbody one.\n\n## 2026-07-20\n\nbody two.\n`;
274
+ const a = parseChangelogText(lf);
275
+ const b = parseChangelogText(lf.replace(/\n/g, '\r\n'));
276
+ assert.deepEqual(b.entries.map(identity), a.entries.map(identity));
277
+ assert.equal(b.frontmatter.replace(/\r\n/g, '\n'), a.frontmatter);
278
+ });
279
+
280
+ it('doorway six: slash, single-digit, truncated, unseparated and impossible dates refuse', () => {
281
+ for (const heading of MALFORMED) {
282
+ const text = between(heading);
283
+ let threw = null;
284
+ try {
285
+ parseChangelogText(text);
286
+ } catch (err) {
287
+ threw = err;
288
+ }
289
+ assert.ok(threw, `"${heading}" fell through silently instead of refusing`);
290
+ assertTypedRefusal(threw, `"${heading}"`);
291
+ }
292
+ });
293
+ });
294
+
295
+ // ── properties over generated documents ───────────────────────────────────────────────
296
+
297
+ describe('rotation properties over generated documents', () => {
298
+ const SEEDS = 300;
299
+
300
+ it('every body line lands in exactly one bucket, or the parse refuses loudly', () => {
301
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
302
+ const doc = buildDoc(seed);
303
+ let parsed;
304
+ try {
305
+ parsed = parseChangelogText(doc.text);
306
+ } catch (err) {
307
+ assertTypedRefusal(err, `seed ${seed}`);
308
+ continue;
309
+ }
310
+ assert.deepEqual(conservationDelta(doc.text, parsed), { missing: [], duplicated: [] }, `seed ${seed}`);
311
+ }
312
+ });
313
+
314
+ it('a document whose unit-shaped headings all parse yields exactly the generated entry set', () => {
315
+ let covered = 0;
316
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
317
+ const doc = buildDoc(seed);
318
+ if (doc.mustRefuse) continue;
319
+ covered += 1;
320
+ const parsed = parseChangelogText(doc.text);
321
+ assert.deepEqual(parsed.entries.map(identity).sort(), [...doc.entries].sort(), `seed ${seed}`);
322
+ }
323
+ assert.ok(covered >= 50, `the generator must produce clean documents (got ${covered})`);
324
+ });
325
+
326
+ it('an unparseable unit-shaped heading or an unclosed fence refuses loudly, never absorbs', () => {
327
+ let covered = 0;
328
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
329
+ const doc = buildDoc(seed);
330
+ if (!doc.mustRefuse) continue;
331
+ covered += 1;
332
+ let threw = null;
333
+ try {
334
+ parseChangelogText(doc.text);
335
+ } catch (err) {
336
+ threw = err;
337
+ }
338
+ assert.ok(threw, `seed ${seed}: an unparseable unit-shaped heading was silently absorbed`);
339
+ assertTypedRefusal(threw, `seed ${seed}`);
340
+ }
341
+ assert.ok(covered >= 20, `the generator must produce refusable documents (got ${covered})`);
342
+ });
343
+
344
+ it('a full rotation re-parses to the same entry set — nothing lost, nothing duplicated', () => {
345
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
346
+ const doc = buildDoc(seed);
347
+ if (doc.mustRefuse) continue;
348
+ let parsed;
349
+ try {
350
+ parsed = parseChangelogText(doc.text);
351
+ } catch (err) {
352
+ assertTypedRefusal(err, `seed ${seed}`);
353
+ continue;
354
+ }
355
+ const { tiers } = rotateInMemory(parsed);
356
+ const reparsed = [];
357
+ for (const [tier, tierText] of tiers) {
358
+ let again;
359
+ try {
360
+ again = parseChangelogText(tierText);
361
+ } catch (err) {
362
+ assert.fail(`seed ${seed}: the rotator refused its own ${tier} output — ${err.message}`);
363
+ }
364
+ reparsed.push(...again.entries.map(identity));
365
+ }
366
+ assert.deepEqual(reparsed.sort(), parsed.entries.map(identity).sort(), `seed ${seed}`);
367
+ }
368
+ });
369
+
370
+ it('the rotator accepts its own writer output — every tier it writes tokenizes cleanly', () => {
371
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
372
+ const doc = buildDoc(seed);
373
+ if (doc.mustRefuse) continue;
374
+ let parsed;
375
+ try {
376
+ parsed = parseChangelogText(doc.text);
377
+ } catch (err) {
378
+ assertTypedRefusal(err, `seed ${seed}`);
379
+ continue;
380
+ }
381
+ const { tiers } = rotateInMemory(parsed);
382
+ for (const [tier, tierText] of tiers) {
383
+ try {
384
+ tokenizeMarkdown(tierText, tier);
385
+ } catch (err) {
386
+ assert.fail(`seed ${seed}: the writer emitted a ${tier} tier the tokenizer refuses — ${err.message}`);
387
+ }
388
+ }
389
+ }
390
+ });
391
+
392
+ it('the generator reaches all three tiers across the seed range', () => {
393
+ const cutoffs = computeCutoffs(TODAY, 3, 30);
394
+ let hotSeen = 0;
395
+ let warmSeen = 0;
396
+ let coldSeen = 0;
397
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
398
+ const doc = buildDoc(seed);
399
+ if (doc.mustRefuse) continue;
400
+ const entries = doc.entries.map((identityStr) => {
401
+ const [dateStr] = identityStr.split('|');
402
+ const [year, month, day] = dateStr.split('.');
403
+ return { dateObj: new Date(`${year}-${month}-${day}T00:00:00Z`) };
404
+ });
405
+ const { hot, warm, cold } = categorize(entries, cutoffs);
406
+ hotSeen += hot.length;
407
+ warmSeen += warm.length;
408
+ coldSeen += cold.length;
409
+ }
410
+ assert.ok(hotSeen > 0 && warmSeen > 0 && coldSeen > 0, `tier reach: hot ${hotSeen} / warm ${warmSeen} / cold ${coldSeen}`);
411
+ });
412
+
413
+ it('rewriting an already-written WARM or COLD tier is a byte fixed point', () => {
414
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
415
+ const doc = buildDoc(seed);
416
+ if (doc.mustRefuse) continue;
417
+ let parsed;
418
+ try {
419
+ parsed = parseChangelogText(doc.text);
420
+ } catch (err) {
421
+ assertTypedRefusal(err, `seed ${seed}`);
422
+ continue;
423
+ }
424
+ const { tiers } = rotateInMemory(parsed);
425
+ for (const [tier, tierText] of tiers) {
426
+ if (tier === 'HOT') continue;
427
+ const again = parseChangelogText(tierText, tier).entries;
428
+ const rebuilt = tier === 'WARM'
429
+ ? buildRecent(again, TODAY)
430
+ : buildCold(...tier.slice('COLD '.length).split('-'), again, TODAY);
431
+ assert.equal(rebuilt, tierText, `seed ${seed}: ${tier} is not a fixed point`);
432
+ }
433
+ }
434
+ });
435
+
436
+ it('rotating an already-rotated HOT tier is a byte fixed point', () => {
437
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
438
+ const doc = buildDoc(seed);
439
+ if (doc.mustRefuse) continue;
440
+ let parsed;
441
+ try {
442
+ parsed = parseChangelogText(doc.text);
443
+ } catch (err) {
444
+ assertTypedRefusal(err, `seed ${seed}`);
445
+ continue;
446
+ }
447
+ const { tiers, hot, warm, cold } = rotateInMemory(parsed);
448
+ // Stated scope cut, not a silent cap: with ZERO hot entries and a footer, the rebuilt file
449
+ // holds a "## Footer" with no entry before it, so the re-parse folds it into the preamble
450
+ // and the second build re-orders — the known zero-unit+footer corner (council round 2,
451
+ // both backends: document, don't fold — a footer boundary with zero entries would
452
+ // reintroduce the pinned "## History slurped every entry" mis-detection).
453
+ if (hot.length === 0) continue;
454
+ const hotText = tiers[0][1];
455
+ const second = parseChangelogText(hotText);
456
+ const rebuilt = buildChangelog({
457
+ frontmatter: second.frontmatter || FM,
458
+ preamble: second.preamble || '# Changelog',
459
+ hot: second.entries,
460
+ footer: second.footer,
461
+ hasArchive: warm.length > 0 || cold.length > 0,
462
+ });
463
+ assert.equal(rebuilt, hotText, `seed ${seed}`);
464
+ }
465
+ });
466
+ });
@@ -29,7 +29,10 @@
29
29
  // HOT preamble, and only THEN removes the monoliths — gated on conservation AND
30
30
  // the snapshot. Re-run skips byte-identical records (crash-resumable).
31
31
  // --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
32
- // supersession write-side; the --write-index analog).
32
+ // supersession write-side; the --write-index analog). With --dry-run it runs
33
+ // EXACTLY the same validation (parse, half-migrated guard, store integrity) and
34
+ // stops before every write — the read-only preflight a guarded caller needs to
35
+ // earn a go-ahead without risking a partial write.
33
36
  // --dry-run print the planned rotation move-set, change nothing.
34
37
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
35
38
  //
@@ -54,6 +57,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
54
57
  import { spawnSync } from 'node:child_process';
55
58
  import { createHash } from 'node:crypto';
56
59
  import { tmpdir } from 'node:os';
60
+ import { tokenizeMarkdown } from './markdown-blocks.mjs';
57
61
 
58
62
  const __dirname = dirname(fileURLToPath(import.meta.url));
59
63
  const DEFAULT_ROOT = resolve(__dirname, '..');
@@ -75,8 +79,10 @@ const NAV_RECENT_WINDOW = 15;
75
79
 
76
80
  // AD-\d{3,}: 3-digit ids stay valid, AD-1000+ parse; ordering is always NUMERIC (never lexical).
77
81
  export const HEADING_RE = /^## AD-(\d{3,}) — (.+)$/;
78
- const ANY_H2_RE = /^## /;
79
- const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/;
82
+ // AD-SHAPE, deliberately wider than the grammar: the AD- prefix at ANY level and indent (space or
83
+ // tab separated), so `### AD-051 — …`, ` ## AD-051 — …` and a tab-separated form refuse loudly
84
+ // instead of being absorbed as body text.
85
+ const AD_SHAPED_HEADING_RE = /^\s*#{1,6}[ \t]+AD-\d/;
80
86
  const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
81
87
 
82
88
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
@@ -123,26 +129,38 @@ const extractLifecycle = (block) => {
123
129
  return { status, date, supersedes, supersededBy };
124
130
  };
125
131
 
126
- // Parse one tier's text → { frontmatter, cap, preamble, entries }. Every `## ` line must be a
127
- // canonical AD heading anything else is exit 1 naming file:line (Issue-009).
132
+ // Parse one tier's text → { frontmatter, cap, preamble, entries }. The text is read through the
133
+ // shared block tokenizer, so a `## ` line inside a fenced sample is BODY (an ADR documenting a
134
+ // heading format is no longer falsely refused), an unclosed fence is a loud error, and CRLF never
135
+ // changes a parse. Every column-0 H2 heading TOKEN must be a canonical AD heading, and an
136
+ // AD-shaped heading at any other level or indent refuses too — anything else is exit 1 naming
137
+ // file:line (Issue-009).
128
138
  export const parseDecisionsText = (text, label) => {
129
- const fmMatch = text.match(FRONTMATTER_RE);
130
- const frontmatter = fmMatch ? fmMatch[1] : '';
131
- const fmLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
132
- const rest = text.slice(frontmatter.length);
133
- const lines = rest.split('\n');
139
+ const { frontmatter, frontLines, lines, headings } = tokenizeMarkdown(text, label);
140
+ const fileLine = (index) => frontLines + index + 1;
134
141
 
135
142
  const startIdxs = [];
136
- lines.forEach((line, i) => {
137
- if (!ANY_H2_RE.test(line)) return;
138
- if (!HEADING_RE.test(line)) {
143
+ const matchByIdx = new Map();
144
+ for (const heading of headings) {
145
+ if (heading.level === 2 && heading.text.startsWith('## ')) {
146
+ const m = HEADING_RE.exec(heading.text);
147
+ if (!m) {
148
+ throw fail(
149
+ 1,
150
+ `${label}:${fileLine(heading.index)}: non-canonical H2 heading "${heading.text}" — every "## " heading must be \`## AD-NNN — <title>\` (AD-\\d{3,}; never silently glued to the previous entry; fix the heading, then re-run)`,
151
+ );
152
+ }
153
+ startIdxs.push(heading.index);
154
+ matchByIdx.set(heading.index, m);
155
+ continue;
156
+ }
157
+ if (AD_SHAPED_HEADING_RE.test(heading.text)) {
139
158
  throw fail(
140
159
  1,
141
- `${label}:${fmLines + i + 1}: non-canonical H2 heading "${line}" every "## " heading must be \`## AD-NNN — <title>\` (AD-\\d{3,}; never silently glued to the previous entry; fix the heading, then re-run)`,
160
+ `${label}:${fileLine(heading.index)}: "${heading.text}" is AD-shaped but not a canonical ADR heading expected \`## AD-NNN — <title>\` (level 2, column 0). It would previously have been silently treated as body text; fix the heading, then re-run`,
142
161
  );
143
162
  }
144
- startIdxs.push(i);
145
- });
163
+ }
146
164
 
147
165
  const preambleEnd = startIdxs.length > 0 ? startIdxs[0] : lines.length;
148
166
  const preamble = lines.slice(0, preambleEnd).join('\n').trim();
@@ -150,7 +168,7 @@ export const parseDecisionsText = (text, label) => {
150
168
  const entries = startIdxs.map((idx, i) => {
151
169
  const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
152
170
  const blockLines = stripTrailingSeparators(lines.slice(idx, end));
153
- const m = HEADING_RE.exec(lines[idx]);
171
+ const m = matchByIdx.get(idx);
154
172
  const block = blockLines.join('\n');
155
173
  return {
156
174
  id: m[1],
@@ -693,7 +711,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
693
711
  return 0;
694
712
  };
695
713
 
696
- const runWriteNavigator = (root, today, deps, log, logError) => {
714
+ const runWriteNavigator = (root, flags, today, deps, log, logError) => {
697
715
  if (!existsSync(resolve(root, HOT_REL)) && !existsSync(resolve(root, ADR_DIR_REL))) {
698
716
  log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to write.`);
699
717
  return 0;
@@ -704,6 +722,14 @@ const runWriteNavigator = (root, today, deps, log, logError) => {
704
722
  const adrEntries = loadAdrStore(root);
705
723
  assertStoreIntegrity(hotEntries, adrEntries); // never emit a duplicate-row / corrupt navigator
706
724
  const corpus = [...hotEntries, ...adrEntries];
725
+ // --dry-run runs EXACTLY the validation above and stops before every write: the parse, the
726
+ // half-migrated guard and the store-integrity check are the same code the write path uses, so a
727
+ // caller (the guarded ADR-store crossing) can earn a go-ahead without a partial write. A separate
728
+ // re-implementation of these checks would be an approximation that can disagree with the writer.
729
+ if (flags.dryRun) {
730
+ log(`[archive-decisions] --write-navigator DRY-RUN — no files will be changed; ${corpus.length} ADR(s) validated.`);
731
+ return 0;
732
+ }
707
733
  writeNavigatorFile(root, corpus, today);
708
734
  const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
709
735
  log(`[archive-decisions] wrote ${NAV_REL} (${corpus.length} ADRs in the corpus).`);
@@ -740,7 +766,9 @@ const runCheck = (root, today, log, logError) => {
740
766
 
741
767
  const problems = [];
742
768
  if (hot) {
743
- log(`[archive-decisions] ${HOT_REL}: ${hot.rawLines}/${hot.cap}`);
769
+ // Arm B: the verdict names the parsed unit count, not just raw lines — with the loud parse
770
+ // path, zero can only mean genuinely empty, never a populated file read as nothing.
771
+ log(`[archive-decisions] ${HOT_REL}: ${hot.rawLines}/${hot.cap} lines, ${hot.entries.length} ADR(s) in the HOT window`);
744
772
  if (hot.cap !== null && hot.rawLines > hot.cap) problems.push(`${HOT_REL} is over its cap (${hot.rawLines}/${hot.cap}) — run \`node scripts/archive-decisions.mjs\` to explode the oldest entries`);
745
773
  }
746
774
  log(`[archive-decisions] ${ADR_DIR_REL}: ${adrEntries.length} record(s)`);
@@ -828,7 +856,7 @@ export const runCli = (argv, deps = {}) => {
828
856
  const today = todayOpt ?? new Date().toISOString().slice(0, 10);
829
857
 
830
858
  if (flags.migrate) return runMigrate(root, flags, today, deps, log, logError);
831
- if (flags.writeNavigator) return runWriteNavigator(root, today, deps, log, logError);
859
+ if (flags.writeNavigator) return runWriteNavigator(root, flags, today, deps, log, logError);
832
860
  if (flags.check) return runCheck(root, today, log, logError);
833
861
 
834
862
  if (!existsSync(resolve(root, HOT_REL))) {