@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.
- package/CHANGELOG.md +51 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/migrate-adr-store.md +4 -2
- package/references/modes/recommendations.md +2 -0
- package/references/modes/status.md +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 +48 -20
- package/references/scripts/archive-decisions.test.mjs +120 -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/family-registry.mjs +78 -12
- package/tools/known-footprint.mjs +5 -1
- package/tools/migrate-adr-store.mjs +206 -10
- package/tools/recommendations.mjs +41 -2
- package/tools/renderers.mjs +8 -2
|
@@ -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
|
-
|
|
79
|
-
|
|
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 }.
|
|
127
|
-
//
|
|
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
|
|
130
|
-
const
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
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)) {
|
|
139
158
|
throw fail(
|
|
140
159
|
1,
|
|
141
|
-
`${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`,
|
|
142
161
|
);
|
|
143
162
|
}
|
|
144
|
-
|
|
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 =
|
|
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
|
-
|
|
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))) {
|