@sabaiway/agent-workflow-kit 4.5.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.
- package/CHANGELOG.md +20 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/upgrade.md +2 -2
- package/references/scripts/archive-changelog.mjs +300 -192
- package/references/scripts/archive-changelog.test.mjs +341 -0
- package/references/scripts/archive-conservation.test.mjs +466 -0
- package/references/scripts/archive-decisions.mjs +34 -17
- package/references/scripts/archive-decisions.test.mjs +93 -0
- package/references/scripts/archive-issues.mjs +344 -108
- package/references/scripts/archive-issues.test.mjs +762 -32
- package/references/scripts/archiver-structure.test.mjs +39 -0
- package/references/scripts/markdown-blocks.mjs +143 -0
- package/references/scripts/markdown-blocks.test.mjs +310 -0
- package/references/templates/changelog.md +3 -1
- package/references/templates/known_issues.md +13 -5
- package/tools/known-footprint.mjs +5 -1
- package/tools/migrate-adr-store.mjs +32 -5
|
@@ -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
|
+
});
|
|
@@ -57,6 +57,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
57
57
|
import { spawnSync } from 'node:child_process';
|
|
58
58
|
import { createHash } from 'node:crypto';
|
|
59
59
|
import { tmpdir } from 'node:os';
|
|
60
|
+
import { tokenizeMarkdown } from './markdown-blocks.mjs';
|
|
60
61
|
|
|
61
62
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
62
63
|
const DEFAULT_ROOT = resolve(__dirname, '..');
|
|
@@ -78,8 +79,10 @@ const NAV_RECENT_WINDOW = 15;
|
|
|
78
79
|
|
|
79
80
|
// AD-\d{3,}: 3-digit ids stay valid, AD-1000+ parse; ordering is always NUMERIC (never lexical).
|
|
80
81
|
export const HEADING_RE = /^## AD-(\d{3,}) — (.+)$/;
|
|
81
|
-
|
|
82
|
-
|
|
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/;
|
|
83
86
|
const RECORD_FILE_RE = /^AD-(\d{3,})-.*\.md$/;
|
|
84
87
|
|
|
85
88
|
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
@@ -126,26 +129,38 @@ const extractLifecycle = (block) => {
|
|
|
126
129
|
return { status, date, supersedes, supersededBy };
|
|
127
130
|
};
|
|
128
131
|
|
|
129
|
-
// Parse one tier's text → { frontmatter, cap, preamble, entries }.
|
|
130
|
-
//
|
|
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).
|
|
131
138
|
export const parseDecisionsText = (text, label) => {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const fmLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
|
|
135
|
-
const rest = text.slice(frontmatter.length);
|
|
136
|
-
const lines = rest.split('\n');
|
|
139
|
+
const { frontmatter, frontLines, lines, headings } = tokenizeMarkdown(text, label);
|
|
140
|
+
const fileLine = (index) => frontLines + index + 1;
|
|
137
141
|
|
|
138
142
|
const startIdxs = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
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)) {
|
|
142
158
|
throw fail(
|
|
143
159
|
1,
|
|
144
|
-
`${label}:${
|
|
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`,
|
|
145
161
|
);
|
|
146
162
|
}
|
|
147
|
-
|
|
148
|
-
});
|
|
163
|
+
}
|
|
149
164
|
|
|
150
165
|
const preambleEnd = startIdxs.length > 0 ? startIdxs[0] : lines.length;
|
|
151
166
|
const preamble = lines.slice(0, preambleEnd).join('\n').trim();
|
|
@@ -153,7 +168,7 @@ export const parseDecisionsText = (text, label) => {
|
|
|
153
168
|
const entries = startIdxs.map((idx, i) => {
|
|
154
169
|
const end = i + 1 < startIdxs.length ? startIdxs[i + 1] : lines.length;
|
|
155
170
|
const blockLines = stripTrailingSeparators(lines.slice(idx, end));
|
|
156
|
-
const m =
|
|
171
|
+
const m = matchByIdx.get(idx);
|
|
157
172
|
const block = blockLines.join('\n');
|
|
158
173
|
return {
|
|
159
174
|
id: m[1],
|
|
@@ -751,7 +766,9 @@ const runCheck = (root, today, log, logError) => {
|
|
|
751
766
|
|
|
752
767
|
const problems = [];
|
|
753
768
|
if (hot) {
|
|
754
|
-
|
|
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`);
|
|
755
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`);
|
|
756
773
|
}
|
|
757
774
|
log(`[archive-decisions] ${ADR_DIR_REL}: ${adrEntries.length} record(s)`);
|
|
@@ -111,6 +111,99 @@ const run = (argv, root, opts = {}) => {
|
|
|
111
111
|
const adrFiles = (root) => (existsSync(join(root, ADR_DIR_REL)) ? readdirSync(join(root, ADR_DIR_REL)).filter((n) => /^AD-\d{3,}-/.test(n)).sort() : []);
|
|
112
112
|
const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
|
|
113
113
|
|
|
114
|
+
// ── tokenizer contract (Phase 2) — fences, CRLF and mis-levelled AD headings ──────────
|
|
115
|
+
//
|
|
116
|
+
// The parser reads through markdown-blocks.mjs: a `## ` line inside a fenced sample is BODY (the
|
|
117
|
+
// shipped false-refusal — an ADR documenting the changelog heading format was refused), an
|
|
118
|
+
// unclosed fence is loud, CRLF never changes a parse, and an AD-shaped heading at the wrong level
|
|
119
|
+
// or indent refuses instead of being silently absorbed as body text.
|
|
120
|
+
|
|
121
|
+
describe('tokenizer contract — fences, CRLF and mis-levelled AD headings', () => {
|
|
122
|
+
const refusal = (fn) => {
|
|
123
|
+
let threw = null;
|
|
124
|
+
try {
|
|
125
|
+
fn();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
threw = err;
|
|
128
|
+
}
|
|
129
|
+
assert.ok(threw, 'expected a typed refusal');
|
|
130
|
+
assert.equal(threw.exitCode, 1, `a refusal carries exitCode 1 (${threw.message})`);
|
|
131
|
+
return threw;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
it('an ADR documenting the changelog format parses — a fenced non-AD sample is body', () => {
|
|
135
|
+
const block = [
|
|
136
|
+
'## AD-050 — documents the changelog heading format',
|
|
137
|
+
'',
|
|
138
|
+
'**Date:** 2026-01-01 · **Status:** Accepted',
|
|
139
|
+
'',
|
|
140
|
+
'Write entries like:',
|
|
141
|
+
'',
|
|
142
|
+
'```markdown',
|
|
143
|
+
'## 2026-07-20 — an example entry',
|
|
144
|
+
'```',
|
|
145
|
+
'',
|
|
146
|
+
'end.',
|
|
147
|
+
].join('\n');
|
|
148
|
+
const p = parseDecisionsText(tierText(500, '# T', [block]), 'x');
|
|
149
|
+
assert.deepEqual(p.entries.map((e) => e.id), ['050']);
|
|
150
|
+
assert.match(p.entries[0].block, /## 2026-07-20 — an example entry/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a fenced AD-heading sample never splits the enclosing block either', () => {
|
|
154
|
+
const block = [
|
|
155
|
+
'## AD-050 — shows a record heading',
|
|
156
|
+
'',
|
|
157
|
+
'```markdown',
|
|
158
|
+
'## AD-999 — a sample record heading',
|
|
159
|
+
'```',
|
|
160
|
+
'',
|
|
161
|
+
'end.',
|
|
162
|
+
].join('\n');
|
|
163
|
+
const p = parseDecisionsText(tierText(500, '# T', [block]), 'x');
|
|
164
|
+
assert.deepEqual(p.entries.map((e) => e.id), ['050']);
|
|
165
|
+
assert.match(p.entries[0].block, /AD-999/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('an unclosed fence refuses loudly instead of being scanned through', () => {
|
|
169
|
+
const block = ['## AD-050 — opens a fence', '', '```markdown', '## AD-051 — hidden behind it'].join('\n');
|
|
170
|
+
const err = refusal(() => parseDecisionsText(tierText(500, '# T', [block]), 'x'));
|
|
171
|
+
assert.match(err.message, /never closed/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('an AD-shaped heading at the wrong level or indent refuses naming file and line', () => {
|
|
175
|
+
for (const bad of ['### AD-051 — wrong level', ' ## AD-051 — indented', '##\tAD-051 — tab separated']) {
|
|
176
|
+
const err = refusal(() => parseDecisionsText(tierText(500, '# T', [adrBlock('050'), `${bad}\n\norphan body`]), 'x'));
|
|
177
|
+
assert.match(err.message, /^x:\d+:/);
|
|
178
|
+
assert.match(err.message, /AD-051/);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('a CRLF decisions file parses identically to its LF twin', () => {
|
|
183
|
+
const lf = tierText(500, '# T', [adrBlock('050'), adrBlock('051')]);
|
|
184
|
+
const a = parseDecisionsText(lf, 'x');
|
|
185
|
+
const b = parseDecisionsText(lf.replace(/\n/g, '\r\n'), 'x');
|
|
186
|
+
assert.deepEqual(b.entries.map((e) => e.id), a.entries.map((e) => e.id));
|
|
187
|
+
assert.deepEqual(b.entries.map((e) => e.status), a.entries.map((e) => e.status));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('the check verdict names the parsed HOT ADR count, zero included', () => {
|
|
191
|
+
const withTwo = makeRoot();
|
|
192
|
+
seedLegacy(withTwo, { hot: ['050', '051'] });
|
|
193
|
+
assert.equal(run(['--write-navigator'], withTwo).code, 0);
|
|
194
|
+
const two = run(['--check'], withTwo);
|
|
195
|
+
assert.equal(two.code, 0);
|
|
196
|
+
assert.match(two.text, /2 ADR\(s\) in the HOT window/);
|
|
197
|
+
|
|
198
|
+
const empty = makeRoot();
|
|
199
|
+
seedLegacy(empty, { hot: [] });
|
|
200
|
+
assert.equal(run(['--write-navigator'], empty).code, 0);
|
|
201
|
+
const zero = run(['--check'], empty);
|
|
202
|
+
assert.equal(zero.code, 0);
|
|
203
|
+
assert.match(zero.text, /0 ADR\(s\) in the HOT window/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
114
207
|
// ── 1.1 — the widened grammar + real-corpus parser + status/date/lifecycle extraction ──
|
|
115
208
|
|
|
116
209
|
describe('1.1 parser — real-corpus formats, widened grammar, verbatim blocks', () => {
|