@sabaiway/agent-workflow-memory 3.2.0 → 4.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 +53 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- 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
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
2
6
|
import { expect } from './_expect-shim.mjs';
|
|
3
7
|
import {
|
|
4
8
|
parseChangelogText,
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
} from './archive-changelog.mjs';
|
|
15
19
|
|
|
16
20
|
const FM = '---\ntype: history\nlastUpdated: 2026-05-24\nmaxLines: 700\n---\n';
|
|
21
|
+
const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
17
22
|
|
|
18
23
|
const makeEntry = (dateStr, title = '') => {
|
|
19
24
|
const [year, month, day] = dateStr.split('.');
|
|
@@ -194,6 +199,342 @@ describe('groupByMonth', () => {
|
|
|
194
199
|
});
|
|
195
200
|
});
|
|
196
201
|
|
|
202
|
+
// ── date-form contract ────────────────────────────────────────────────────────────────
|
|
203
|
+
// Dotted archives exist on disk in every deployed project, so acceptance WIDENS to ISO rather
|
|
204
|
+
// than moving to it. The two describes below are deliberately separated: the first pins behaviour
|
|
205
|
+
// that is already correct (characterization — green before and after, never a red-proof candidate),
|
|
206
|
+
// the second is the genuinely-red set for the widening.
|
|
207
|
+
|
|
208
|
+
describe('date-form characterization — already green, guards a sloppy widening', () => {
|
|
209
|
+
it('keeps dotted entry headings parsing unchanged', () => {
|
|
210
|
+
const text = `${FM}\n# Changelog\n\n## 2026.05.20 — alpha\n\nbody one.\n\n## 2026.05.10 — beta\n\nbody two.\n`;
|
|
211
|
+
const parsed = parseChangelogText(text);
|
|
212
|
+
expect(parsed.entries.map((e) => e.dateStr)).toEqual(['2026.05.20', '2026.05.10']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('accepts a dated heading with no title at all (legacy tolerance, deliberately kept)', () => {
|
|
216
|
+
const parsed = parseChangelogText(`${FM}\n# Changelog\n\n## 2026-07-20\n\nbody.\n`);
|
|
217
|
+
expect(parsed.entries).toHaveLength(1);
|
|
218
|
+
expect(parsed.entries[0].title).toBe('');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves ordinary prose H2 headings alone', () => {
|
|
222
|
+
const text = `${FM}\n# Changelog\n\n## History\n\n> pointer.\n\n---\n\n## 2026.05.20 — alpha\n\nbody.\n\n## Footer\n\nstray.\n`;
|
|
223
|
+
const parsed = parseChangelogText(text);
|
|
224
|
+
expect(parsed.entries).toHaveLength(1);
|
|
225
|
+
expect(parsed.footer).toContain('## Footer');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The loud path (Arm A). A date-shaped heading that does not parse REFUSES, naming file:line —
|
|
230
|
+
// never absorbed as body text, never a silent footer boundary, never normalised. Both halves are
|
|
231
|
+
// pinned: the predicate (what refuses) and the disposition (a typed exitCode-1 error naming the
|
|
232
|
+
// offender, with no partial result escaping).
|
|
233
|
+
const expectRefusal = (fn, messageRe) => {
|
|
234
|
+
let threw = null;
|
|
235
|
+
try {
|
|
236
|
+
fn();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
threw = err;
|
|
239
|
+
}
|
|
240
|
+
expect(threw).not.toBe(null);
|
|
241
|
+
expect(threw.exitCode).toBe(1);
|
|
242
|
+
if (messageRe) expect(threw.message).toMatch(messageRe);
|
|
243
|
+
return threw;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const between = (heading) =>
|
|
247
|
+
`${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\nbody one.\n\n${heading}\n\nORPHAN BODY.\n\n## 2026.01.05 — good two\n\nbody two.\n`;
|
|
248
|
+
|
|
249
|
+
// The title deliberately carries the PRIOR pins' names ("do not parse are LOUD", "the gap the
|
|
250
|
+
// fail-closed change will close"): red-proof records key on {base, testId}, a record whose test
|
|
251
|
+
// was renamed can neither be re-observed nor retired, and it would fail the final gate forever.
|
|
252
|
+
// Carrying the lineage in the title lets one re-observation SUPERSEDE the legacy keys honestly.
|
|
253
|
+
// The missing retirement lane is queued as RED-PROOF-RENAME-LANE.
|
|
254
|
+
describe('unparsed date-like headings refuse loudly: date-like headings that do not parse are LOUD — the gap the fail-closed change will close', () => {
|
|
255
|
+
it('a mixed-separator date refuses instead of falling through', () => {
|
|
256
|
+
expectRefusal(() => parseChangelogText(between('## 2026-07.20 — mixed separators')));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('an impossible calendar date refuses in BOTH forms instead of being normalised', () => {
|
|
260
|
+
for (const heading of ['## 2026.02.30 — impossible dotted', '## 2026-02-30 — impossible iso']) {
|
|
261
|
+
expectRefusal(() => parseChangelogText(between(heading)), /calendar/);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('a wrong-level date heading refuses instead of hiding a whole corpus', () => {
|
|
266
|
+
expectRefusal(() => parseChangelogText(between('### 2026-07-20 — wrong level')));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('an indented date heading refuses instead of being glued', () => {
|
|
270
|
+
expectRefusal(() => parseChangelogText(between(' ## 2026-07-19 — indented')));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('a tab-separated date heading refuses instead of vanishing', () => {
|
|
274
|
+
expectRefusal(() => parseChangelogText(between('##\t2026-07-18 — tab separated')));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('the refusal names the file label and the 1-based line of the first offender', () => {
|
|
278
|
+
const err = expectRefusal(() => parseChangelogText(between('## 2026/07/20 — slash'), 'docs/ai/changelog.md'));
|
|
279
|
+
// FM is 5 lines; the offender sits 8 body lines further down.
|
|
280
|
+
expect(err.message).toMatch(/^docs\/ai\/changelog\.md:13:/);
|
|
281
|
+
expect(err.message).toContain('## 2026/07/20 — slash');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Same lineage-carrying title as above ("fenced code blocks are never scanned", "second gap the
|
|
286
|
+
// fail-closed change will close") — see the RED-PROOF-RENAME-LANE note.
|
|
287
|
+
describe('fenced regions are invisible to the entry grammar: fenced code blocks are never scanned — the second gap the fail-closed change will close', () => {
|
|
288
|
+
const FENCE = '```';
|
|
289
|
+
|
|
290
|
+
it('a heading inside a closed fence is never an entry, in either separator form', () => {
|
|
291
|
+
for (const sample of ['## 2026.07.20 — an example entry', '## 2026-07-20 — an example entry']) {
|
|
292
|
+
const text = `${FM}\n# Changelog\n\n## 2026.07.21 — teaches the format\n\nWrite entries like this:\n\n${FENCE}markdown\n${sample}\n${FENCE}\n\nend of body.\n`;
|
|
293
|
+
const parsed = parseChangelogText(text);
|
|
294
|
+
expect(parsed.entries.map((e) => e.dateStr)).toEqual(['2026.07.21']);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('an unclosed fence refuses loudly naming its opening line', () => {
|
|
299
|
+
const text = `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\n${FENCE}markdown\n## 2026.07.20 — hidden\n`;
|
|
300
|
+
expectRefusal(() => parseChangelogText(text), /never closed/);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('structure refusals and the zero-corpus policy', () => {
|
|
305
|
+
it('an entry heading after the footer boundary refuses instead of duplicating into the footer', () => {
|
|
306
|
+
const text = `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\nbody.\n\n## Footer\n\nstray.\n\n## 2026.07.20 — after the footer\n\nlate body.\n`;
|
|
307
|
+
expectRefusal(() => parseChangelogText(text), /footer/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('a rotated-empty tier with a preamble and no unit-shaped headings parses green with zero entries', () => {
|
|
311
|
+
const text = `${FM}\n# Changelog\n\n## History\n\n> older sessions are layered.\n\n---\n`;
|
|
312
|
+
const parsed = parseChangelogText(text);
|
|
313
|
+
expect(parsed.entries).toEqual([]);
|
|
314
|
+
expect(parsed.preamble).toContain('# Changelog');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('a CRLF document parses identically, untitled ISO heading included', () => {
|
|
318
|
+
const lf = `${FM}\n# Changelog\n\n## 2026.07.21 — titled\n\nbody one.\n\n## 2026-07-20\n\nbody two.\n`;
|
|
319
|
+
const a = parseChangelogText(lf);
|
|
320
|
+
const b = parseChangelogText(lf.replace(/\n/g, '\r\n'));
|
|
321
|
+
expect(b.entries.map((e) => `${e.dateStr}|${e.title}`)).toEqual(a.entries.map((e) => `${e.dateStr}|${e.title}`));
|
|
322
|
+
expect(b.frontmatter.replace(/\r\n/g, '\n')).toBe(a.frontmatter);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('date-form contract — ISO accepted alongside dots', () => {
|
|
327
|
+
it('accepts an ISO entry heading', () => {
|
|
328
|
+
const text = `${FM}\n# Changelog\n\n## 2026-07-20 — iso alpha\n\nbody.\n`;
|
|
329
|
+
const parsed = parseChangelogText(text);
|
|
330
|
+
expect(parsed.entries).toHaveLength(1);
|
|
331
|
+
expect(parsed.entries[0].title).toBe('iso alpha');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('parses every entry of a MIXED dotted+ISO file and leaves footer empty', () => {
|
|
335
|
+
const text = `${FM}\n# Changelog\n\n## 2026.07.20 — alpha\n\nbody a.\n\n## 2026-06-15 — beta\n\nbody b.\n\n## 2026.05.10 — gamma\n\nbody g.\n`;
|
|
336
|
+
const parsed = parseChangelogText(text);
|
|
337
|
+
expect(parsed.entries).toHaveLength(3);
|
|
338
|
+
expect(parsed.footer).toBe('');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('treats a genuine "## Footer" after an ISO entry as the footer boundary', () => {
|
|
342
|
+
const text = `${FM}\n# Changelog\n\n## 2026-07-20 — alpha\n\nbody.\n\n## Footer\n\nstray.\n`;
|
|
343
|
+
const parsed = parseChangelogText(text);
|
|
344
|
+
expect(parsed.entries).toHaveLength(1);
|
|
345
|
+
expect(parsed.footer).toContain('## Footer');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('both separator forms of one entry parse, then share one dedupe identity', () => {
|
|
349
|
+
const text = `${FM}\n# Changelog\n\n## 2026-05-12 — twin\n\nbody.\n\n## 2026.05.12 — twin\n\nbody.\n`;
|
|
350
|
+
const parsed = parseChangelogText(text);
|
|
351
|
+
// BOTH halves are asserted on purpose: a bare "dedupes to one" is green pre-fix, because the
|
|
352
|
+
// ISO twin is simply invisible to the dotted-only parser and one entry is all there ever was.
|
|
353
|
+
expect(parsed.entries).toHaveLength(2);
|
|
354
|
+
const identities = new Set(parsed.entries.map((e) => `${e.dateStr}|${e.title}`));
|
|
355
|
+
expect(identities.size).toBe(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('renders an archived ISO entry date in its SOURCE form in the condensed index', () => {
|
|
359
|
+
const text = `${FM}\n# Changelog\n\n## 2026-05-12 — iso cold\n\nbody.\n`;
|
|
360
|
+
const [entry] = parseChangelogText(text).entries;
|
|
361
|
+
const index = buildCondensedIndex([entry], new Map(), '2026-07-28');
|
|
362
|
+
expect(index).toMatch(/\*\*2026-05-12\*\*/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('re-emits every entry heading verbatim across HOT, WARM and COLD', () => {
|
|
366
|
+
const text = `${FM}\n# Changelog\n\n## 2026-05-12 — iso cold\n\nbody i.\n\n## 2026.05.10 — dotted cold\n\nbody d.\n`;
|
|
367
|
+
const { entries } = parseChangelogText(text);
|
|
368
|
+
expect(entries).toHaveLength(2);
|
|
369
|
+
const hot = buildChangelog({ frontmatter: FM, preamble: '# Changelog', hot: entries, footer: '', hasArchive: false });
|
|
370
|
+
const warm = buildRecent(entries, '2026-07-28');
|
|
371
|
+
const cold = buildCold('2026', '05', entries, '2026-07-28');
|
|
372
|
+
for (const rendered of [hot, warm, cold]) {
|
|
373
|
+
expect(rendered).toContain('## 2026-05-12 — iso cold');
|
|
374
|
+
expect(rendered).toContain('## 2026.05.10 — dotted cold');
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// In the PACKAGE this file sits beside references/templates/ and the seed is asserted; the
|
|
379
|
+
// DEPLOYED copy runs in a consumer's scripts/ where no ../templates exists — a stated skip, not
|
|
380
|
+
// an ENOENT crash (the canon-side kit template-parity suite still pins the seed every run).
|
|
381
|
+
it('parses the seeded bootstrap heading shipped in references/templates/changelog.md', { skip: !existsSync(resolve(TEMPLATES_DIR, 'changelog.md')) && 'deployed copy: the template ships in the package, not at the consumer' }, () => {
|
|
382
|
+
const template = readFileSync(resolve(TEMPLATES_DIR, 'changelog.md'), 'utf8');
|
|
383
|
+
const seeded = template.replaceAll('{{DATE}}', '2026-07-28');
|
|
384
|
+
const parsed = parseChangelogText(seeded);
|
|
385
|
+
expect(parsed.entries).toHaveLength(1);
|
|
386
|
+
expect(parsed.entries[0].title).toBe('Bootstrap');
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Arm B at the CLI seam. `runCli` is imported dynamically so this file still LOADS against a
|
|
391
|
+
// parser that predates it — the tests then fail as honest reds instead of taking the whole
|
|
392
|
+
// suite down with a module-load error.
|
|
393
|
+
describe('reading modes agree on refusal and write nothing', () => {
|
|
394
|
+
const seedTree = (dir, text) => {
|
|
395
|
+
mkdirSync(join(dir, 'docs/ai'), { recursive: true });
|
|
396
|
+
writeFileSync(join(dir, 'docs/ai/changelog.md'), text, 'utf8');
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
for (const mode of [['--check'], ['--dry-run'], []]) {
|
|
400
|
+
it(`${JSON.stringify(mode)} refuses the same malformed heading with exit 1 and leaves the tree untouched`, async () => {
|
|
401
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
402
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
403
|
+
try {
|
|
404
|
+
seedTree(dir, between('## 2026/07/20 — slash'));
|
|
405
|
+
const before = readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8');
|
|
406
|
+
const errs = [];
|
|
407
|
+
const code = runCli(mode, { root: dir, log: () => {}, logError: (m) => errs.push(m) });
|
|
408
|
+
expect(code).toBe(1);
|
|
409
|
+
expect(errs.join('\n')).toContain('docs/ai/changelog.md:13');
|
|
410
|
+
expect(readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8')).toBe(before);
|
|
411
|
+
expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it('the check verdict names the counts it acted on', async () => {
|
|
419
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
420
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
421
|
+
try {
|
|
422
|
+
seedTree(dir, `${FM}\n# Changelog\n\n## 2026-07-28 — fresh\n\nbody.\n`);
|
|
423
|
+
const logs = [];
|
|
424
|
+
const code = runCli(['--check', '--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) });
|
|
425
|
+
expect(code).toBe(0);
|
|
426
|
+
const out = logs.join('\n');
|
|
427
|
+
expect(out).toContain('1 parsed entries');
|
|
428
|
+
expect(out).toContain('HOT 1');
|
|
429
|
+
} finally {
|
|
430
|
+
rmSync(dir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe('rotation end to end through runCli', () => {
|
|
436
|
+
const seedTree = (dir, rel, text) => {
|
|
437
|
+
mkdirSync(join(dir, 'docs/ai'), { recursive: true });
|
|
438
|
+
writeFileSync(join(dir, rel), text, 'utf8');
|
|
439
|
+
};
|
|
440
|
+
const FULL = `${FM}\n# Changelog\n\n## 2026-07-28 — hot iso\n\nbody hot.\n\n## 2026.07.10 — warm dotted\n\nbody warm.\n\n## 2026.03.02 — cold dotted\n\n**Goal:** cold body.\n\n## Footer\n\nstray.\n`;
|
|
441
|
+
|
|
442
|
+
it('a default run writes every tier, keeps the footer, and a second run is a byte fixed point', async () => {
|
|
443
|
+
const { runCli, parseChangelogText } = await import('./archive-changelog.mjs');
|
|
444
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
445
|
+
try {
|
|
446
|
+
seedTree(dir, 'docs/ai/changelog.md', FULL);
|
|
447
|
+
seedTree(dir, 'docs/ai/changelog-archive.md', `${FM}\n# Legacy\n\n## 2026.03.05 — legacy one\n\nbody legacy.\n`);
|
|
448
|
+
const code = runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} });
|
|
449
|
+
expect(code).toBe(0);
|
|
450
|
+
expect(existsSync(join(dir, 'docs/ai/history/recent.md'))).toBe(true);
|
|
451
|
+
expect(existsSync(join(dir, 'docs/ai/history/2026-03.md'))).toBe(true);
|
|
452
|
+
expect(existsSync(join(dir, 'docs/ai/history/condensed-index.md'))).toBe(true);
|
|
453
|
+
const hot = readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8');
|
|
454
|
+
const parsed = parseChangelogText(hot);
|
|
455
|
+
expect(parsed.entries.map((e) => e.dateStr)).toEqual(['2026.07.28']);
|
|
456
|
+
expect(parsed.footer).toContain('## Footer');
|
|
457
|
+
expect(readFileSync(join(dir, 'docs/ai/history/2026-03.md'), 'utf8')).toContain('legacy one');
|
|
458
|
+
expect(runCli(['--check', '--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
|
|
459
|
+
const warmBefore = readFileSync(join(dir, 'docs/ai/history/recent.md'), 'utf8');
|
|
460
|
+
expect(runCli(['--today=2026-07-28'], { root: dir, log: () => {}, logError: () => {} })).toBe(0);
|
|
461
|
+
expect(readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8')).toBe(hot);
|
|
462
|
+
expect(readFileSync(join(dir, 'docs/ai/history/recent.md'), 'utf8')).toBe(warmBefore);
|
|
463
|
+
} finally {
|
|
464
|
+
rmSync(dir, { recursive: true, force: true });
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('a dry run prints the per-file census and writes nothing', async () => {
|
|
469
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
470
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
471
|
+
try {
|
|
472
|
+
seedTree(dir, 'docs/ai/changelog.md', FULL);
|
|
473
|
+
const logs = [];
|
|
474
|
+
const code = runCli(['--dry-run', '--today=2026-07-28', '--hot-days=60', '--warm-days=90'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) });
|
|
475
|
+
expect(code).toBe(0);
|
|
476
|
+
const out = logs.join('\n');
|
|
477
|
+
expect(out).toContain('"perFile"');
|
|
478
|
+
expect(out).toContain('"hot": 2');
|
|
479
|
+
expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
|
|
480
|
+
} finally {
|
|
481
|
+
rmSync(dir, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('a zero-corpus default run is a stated no-op — nothing written anywhere', async () => {
|
|
486
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
487
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
488
|
+
try {
|
|
489
|
+
seedTree(dir, 'docs/ai/changelog.md', `${FM}\n# Changelog\n\n> no entries yet.\n`);
|
|
490
|
+
const before = readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8');
|
|
491
|
+
const logs = [];
|
|
492
|
+
const code = runCli(['--today=2026-07-28'], { root: dir, log: (m) => logs.push(m), logError: (m) => logs.push(m) });
|
|
493
|
+
expect(code).toBe(0);
|
|
494
|
+
expect(logs.join('\n')).toContain('nothing to rotate');
|
|
495
|
+
expect(readFileSync(join(dir, 'docs/ai/changelog.md'), 'utf8')).toBe(before);
|
|
496
|
+
expect(existsSync(join(dir, 'docs/ai/history'))).toBe(false);
|
|
497
|
+
} finally {
|
|
498
|
+
rmSync(dir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('a stale HOT tier fails the check naming each overdue entry', async () => {
|
|
503
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
504
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
505
|
+
try {
|
|
506
|
+
seedTree(dir, 'docs/ai/changelog.md', FULL);
|
|
507
|
+
const errs = [];
|
|
508
|
+
const code = runCli(['--check', '--today=2026-07-28'], { root: dir, log: () => {}, logError: (m) => errs.push(m) });
|
|
509
|
+
expect(code).toBe(1);
|
|
510
|
+
const out = errs.join('\n');
|
|
511
|
+
expect(out).toContain('2026.07.10');
|
|
512
|
+
expect(out).toContain('2026.03.02');
|
|
513
|
+
expect(out).toContain('without --check');
|
|
514
|
+
} finally {
|
|
515
|
+
rmSync(dir, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('help exits 0, an unknown argument exits 2, a missing changelog exits 1', async () => {
|
|
520
|
+
const { runCli } = await import('./archive-changelog.mjs');
|
|
521
|
+
const logs = [];
|
|
522
|
+
const errs = [];
|
|
523
|
+
expect(runCli(['--help'], { root: '/nonexistent', log: (m) => logs.push(m), logError: () => {} })).toBe(0);
|
|
524
|
+
expect(logs.join('\n')).toContain('Usage');
|
|
525
|
+
expect(runCli(['--wat'], { root: '/nonexistent', log: () => {}, logError: (m) => errs.push(m) })).toBe(2);
|
|
526
|
+
expect(errs.join('\n')).toContain('unknown argument');
|
|
527
|
+
const dir = mkdtempSync(join(tmpdir(), 'archive-changelog-'));
|
|
528
|
+
try {
|
|
529
|
+
const missing = [];
|
|
530
|
+
expect(runCli([], { root: dir, log: () => {}, logError: (m) => missing.push(m) })).toBe(1);
|
|
531
|
+
expect(missing.join('\n')).toContain('not found');
|
|
532
|
+
} finally {
|
|
533
|
+
rmSync(dir, { recursive: true, force: true });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
197
538
|
describe('idempotency contract', () => {
|
|
198
539
|
it('parse → buildChangelog → parse yields identical entries (idempotency regression)', () => {
|
|
199
540
|
const text = `${FM}\n# Changelog\n\n## History\n\n> pointer.\n\n---\n\n## 2026.05.23 — alpha\n\nbody one.\n\n---\n\n## 2026.05.22 — beta\n\nbody two.\n`;
|