@sabaiway/agent-workflow-kit 1.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -0
  4. package/SKILL.md +121 -0
  5. package/bin/install.mjs +139 -0
  6. package/launchers/README.md +33 -0
  7. package/launchers/install-launchers.sh +94 -0
  8. package/launchers/windsurf-workflow.md +30 -0
  9. package/migrations/README.md +41 -0
  10. package/package.json +46 -0
  11. package/references/planning.md +105 -0
  12. package/references/scripts/_expect-shim.mjs +41 -0
  13. package/references/scripts/archive-changelog.mjs +441 -0
  14. package/references/scripts/archive-changelog.test.mjs +212 -0
  15. package/references/scripts/archive-issues.mjs +179 -0
  16. package/references/scripts/archive-issues.test.mjs +95 -0
  17. package/references/scripts/check-docs-size.mjs +353 -0
  18. package/references/scripts/check-docs-size.test.mjs +180 -0
  19. package/references/scripts/install-git-hooks.mjs +83 -0
  20. package/references/templates/AGENTS.md +78 -0
  21. package/references/templates/active_plan.md +31 -0
  22. package/references/templates/agent_rules.md +85 -0
  23. package/references/templates/architecture.md +49 -0
  24. package/references/templates/changelog.md +24 -0
  25. package/references/templates/current_state.md +36 -0
  26. package/references/templates/decisions.md +44 -0
  27. package/references/templates/env_commands.md +41 -0
  28. package/references/templates/handover.md +37 -0
  29. package/references/templates/known_issues.md +33 -0
  30. package/references/templates/pages/PAGE_TEMPLATE.md +53 -0
  31. package/references/templates/pages/index.md +23 -0
  32. package/references/templates/pages/shared-patterns.md +30 -0
  33. package/references/templates/tech_reference.md +34 -0
  34. package/references/templates/technical_specification.md +37 -0
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ // Rolling-window archive for docs/ai/changelog.md.
3
+ //
4
+ // HOT (changelog.md) — last HOT_DAYS days
5
+ // WARM (history/recent.md) — entries HOT_DAYS..WARM_DAYS old
6
+ // COLD (history/YYYY-MM.md) — entries older than WARM_DAYS, compressed
7
+ // META (history/condensed-index.md) — one-line TL;DRs of every archived entry
8
+ //
9
+ // NOTE (multi-year scaling): condensed-index.md grows O(total archived entries),
10
+ // so on a multi-year horizon it approaches its cap (~1159 lines over 2y in a stress
11
+ // test). When it nears the cap, shard it per-year (condensed-index-YYYY.md) or switch
12
+ // to an append-only cap. Stress-test rotation via the exported pure functions against
13
+ // a /tmp copy seeded with a synthetic multi-year dataset (include burst periods).
14
+ //
15
+ // Modes:
16
+ // (default) run rotation, mutate files in place
17
+ // --dry-run print planned distribution, do not change files
18
+ // --check exit 1 if changelog.md still holds entries that should be archived
19
+ //
20
+ // CLI overrides:
21
+ // --hot-days=N (default 7)
22
+ // --warm-days=N (default 30)
23
+ // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
24
+
25
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
26
+ import { existsSync, readFileSync } from 'node:fs';
27
+ import { dirname, resolve, relative, basename } from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = dirname(__filename);
32
+ const ROOT = resolve(__dirname, '..');
33
+
34
+ const readProjectName = () => {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8'));
37
+ if (pkg.name) return pkg.name;
38
+ } catch {
39
+ /* no package.json — fall back to repo dir basename */
40
+ }
41
+ return basename(ROOT);
42
+ };
43
+ const PROJECT_NAME = readProjectName();
44
+
45
+ const CHANGELOG_PATH = resolve(ROOT, 'docs/ai/changelog.md');
46
+ const HISTORY_DIR = resolve(ROOT, 'docs/ai/history');
47
+ const RECENT_PATH = resolve(HISTORY_DIR, 'recent.md');
48
+ const INDEX_PATH = resolve(HISTORY_DIR, 'condensed-index.md');
49
+
50
+ const DEFAULT_HOT_DAYS = 3;
51
+ const DEFAULT_WARM_DAYS = 30;
52
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
53
+
54
+ const ENTRY_HEADING_RE = /^## (\d{4})\.(\d{2})\.(\d{2})(?: [—–] (.*))?$/;
55
+ const NON_ENTRY_H2_RE = /^## (?!\d{4}\.\d{2}\.\d{2})/;
56
+
57
+ const parseArgs = (argv) => {
58
+ const flags = { dryRun: false, check: false };
59
+ const opts = { hotDays: DEFAULT_HOT_DAYS, warmDays: DEFAULT_WARM_DAYS, today: null };
60
+ for (const arg of argv.slice(2)) {
61
+ if (arg === '--dry-run') flags.dryRun = true;
62
+ else if (arg === '--check') flags.check = true;
63
+ else if (arg.startsWith('--hot-days=')) opts.hotDays = Number(arg.slice('--hot-days='.length));
64
+ else if (arg.startsWith('--warm-days=')) opts.warmDays = Number(arg.slice('--warm-days='.length));
65
+ else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
66
+ else if (arg === '--help' || arg === '-h') {
67
+ console.log(
68
+ 'Usage: archive-changelog.mjs [--dry-run|--check] [--hot-days=N] [--warm-days=N] [--today=YYYY-MM-DD]',
69
+ );
70
+ process.exit(0);
71
+ } else {
72
+ console.error(`Unknown argument: ${arg}`);
73
+ process.exit(2);
74
+ }
75
+ }
76
+ return { flags, opts };
77
+ };
78
+
79
+ export const parseChangelogText = (text) => {
80
+ const fmMatch = text.match(/^(---\n[\s\S]*?\n---\n)/);
81
+ const frontmatter = fmMatch ? fmMatch[1] : '';
82
+ const rest = text.slice(frontmatter.length);
83
+ const lines = rest.split('\n');
84
+
85
+ const entryStartIdxs = [];
86
+ let firstNonEntryH2Idx = -1;
87
+ for (let i = 0; i < lines.length; i += 1) {
88
+ if (ENTRY_HEADING_RE.test(lines[i])) {
89
+ entryStartIdxs.push(i);
90
+ } else if (
91
+ firstNonEntryH2Idx === -1 &&
92
+ entryStartIdxs.length > 0 &&
93
+ NON_ENTRY_H2_RE.test(lines[i])
94
+ ) {
95
+ // Only treat a non-entry H2 as the footer boundary if it appears AFTER at least one date
96
+ // entry. Otherwise a previously-inserted "## History" pointer in the preamble would be
97
+ // mis-detected and cause every entry to be slurped into `footer`.
98
+ firstNonEntryH2Idx = i;
99
+ }
100
+ }
101
+
102
+ const preambleEnd = entryStartIdxs.length > 0 ? entryStartIdxs[0] : lines.length;
103
+ const preamble = lines.slice(0, preambleEnd).join('\n');
104
+
105
+ const entries = entryStartIdxs.map((idx, i) => {
106
+ const isFollowedByEntry = i + 1 < entryStartIdxs.length;
107
+ const tentativeEnd = isFollowedByEntry
108
+ ? entryStartIdxs[i + 1]
109
+ : firstNonEntryH2Idx !== -1 && firstNonEntryH2Idx > idx
110
+ ? firstNonEntryH2Idx
111
+ : lines.length;
112
+ const block = lines.slice(idx, tentativeEnd).join('\n').replace(/\n+$/, '');
113
+ const cleanedBlock = stripTrailingSeparator(block);
114
+ const m = ENTRY_HEADING_RE.exec(lines[idx]);
115
+ return {
116
+ dateStr: `${m[1]}.${m[2]}.${m[3]}`,
117
+ dateObj: new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`),
118
+ year: m[1],
119
+ month: m[2],
120
+ day: m[3],
121
+ title: m[4] ?? '',
122
+ block: cleanedBlock,
123
+ };
124
+ });
125
+
126
+ const footer = firstNonEntryH2Idx !== -1 ? lines.slice(firstNonEntryH2Idx).join('\n').trim() : '';
127
+
128
+ return { frontmatter, preamble: preamble.trim(), entries, footer };
129
+ };
130
+
131
+ const TRAILING_FOOTER_PATTERNS = [
132
+ /^\*\*Last Updated:/i,
133
+ // Legacy in-tree footer line from the deleted changelog-archive.md — match left in place
134
+ // so a re-migration cannot leak the old marker into a freshly-rotated entry.
135
+ /^> Записи старше/i,
136
+ ];
137
+
138
+ export const stripTrailingSeparator = (block) => {
139
+ const lines = block.replace(/\n+$/, '').split('\n');
140
+ const isStripLine = (line) => {
141
+ const trimmed = line.trim();
142
+ if (trimmed === '' || trimmed === '---') return true;
143
+ return TRAILING_FOOTER_PATTERNS.some((re) => re.test(trimmed));
144
+ };
145
+ while (lines.length > 0 && isStripLine(lines[lines.length - 1])) lines.pop();
146
+ return lines.join('\n');
147
+ };
148
+
149
+ export const stripBlockquoteHistoryNotice = (preamble) => {
150
+ const filtered = preamble
151
+ .split('\n')
152
+ .filter((line) => !/changelog-archive\.md/i.test(line) && !/Записи старше/i.test(line));
153
+
154
+ // Strip any previously-inserted "## History" section so re-running the rotator is idempotent.
155
+ // A History section starts at `## History` and ends at the next `---` separator or end-of-file.
156
+ const out = [];
157
+ let inHistorySection = false;
158
+ for (const line of filtered) {
159
+ if (!inHistorySection && /^## History\s*$/.test(line)) {
160
+ inHistorySection = true;
161
+ continue;
162
+ }
163
+ if (inHistorySection) {
164
+ if (line.trim() === '---') {
165
+ inHistorySection = false;
166
+ // Drop the closing separator too — buildChangelog re-emits separators around the new block.
167
+ continue;
168
+ }
169
+ continue;
170
+ }
171
+ out.push(line);
172
+ }
173
+ return out.join('\n').trim();
174
+ };
175
+
176
+ export const computeCutoffs = (todayStr, hotDays, warmDays) => {
177
+ const today = todayStr
178
+ ? new Date(`${todayStr}T00:00:00Z`)
179
+ : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
180
+ // Inclusive window: HOT keeps `hotDays` calendar days ending today.
181
+ return {
182
+ today,
183
+ hotCutoff: new Date(today.getTime() - (hotDays - 1) * MS_PER_DAY),
184
+ warmCutoff: new Date(today.getTime() - (warmDays - 1) * MS_PER_DAY),
185
+ };
186
+ };
187
+
188
+ export const categorize = (entries, cutoffs) => {
189
+ const hot = [];
190
+ const warm = [];
191
+ const cold = [];
192
+ for (const entry of entries) {
193
+ if (entry.dateObj >= cutoffs.hotCutoff) hot.push(entry);
194
+ else if (entry.dateObj >= cutoffs.warmCutoff) warm.push(entry);
195
+ else cold.push(entry);
196
+ }
197
+ return { hot, warm, cold };
198
+ };
199
+
200
+ export const compressEntry = (entry) => {
201
+ const lines = entry.block.split('\n');
202
+ const heading = lines[0];
203
+ const body = lines.slice(1).join('\n');
204
+
205
+ const extractFirstParagraph = (text) => {
206
+ const paragraphs = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
207
+ for (const para of paragraphs) {
208
+ if (para.startsWith('#')) continue;
209
+ if (/^(\*\*Goal|\*\*Problem|\*\*Context|\*\*Why|\*\*Session)/i.test(para)) return para;
210
+ }
211
+ return paragraphs.find((p) => !p.startsWith('#')) ?? '';
212
+ };
213
+
214
+ const extractFileBullets = (text) => {
215
+ const filesSectionMatch = text.match(/\*\*(?:Changes|Files|Files touched|Files changed|Touched)[^\n]*\*\*([\s\S]*?)(?:\n\s*\n|\n##|$)/i);
216
+ if (!filesSectionMatch) return '';
217
+ const bullets = filesSectionMatch[1]
218
+ .split('\n')
219
+ .filter((line) => /^- /.test(line.trim()))
220
+ .slice(0, 8);
221
+ if (bullets.length === 0) return '';
222
+ return ['**Files:**', ...bullets].join('\n');
223
+ };
224
+
225
+ const extractMetric = (text) => {
226
+ const metricsMatch = text.match(/(\d+\s*(?:passed|failed|tests?|warnings?|errors?))/gi);
227
+ if (!metricsMatch || metricsMatch.length === 0) return '';
228
+ return `**Result:** ${metricsMatch.slice(0, 3).join(', ')}`;
229
+ };
230
+
231
+ const summary = extractFirstParagraph(body);
232
+ const files = extractFileBullets(body);
233
+ const metric = extractMetric(body);
234
+
235
+ return [heading, '', summary, files, metric].filter(Boolean).join('\n\n').trim();
236
+ };
237
+
238
+ const summarizeEntry = (entry, sourceLink) => {
239
+ const titleSnippet = entry.title.slice(0, 110).replace(/\n/g, ' ');
240
+ return `- **${entry.dateStr}** — ${titleSnippet} — [${sourceLink}](./${sourceLink})`;
241
+ };
242
+
243
+ const renderEntries = (entries) =>
244
+ entries
245
+ .map((entry) => entry.block.trim())
246
+ .join('\n\n---\n\n');
247
+
248
+ const FRONTMATTER = (type, maxLines, lastUpdated) =>
249
+ `---\ntype: ${type}\nlastUpdated: ${lastUpdated}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${maxLines}\n---\n`;
250
+
251
+ export const buildChangelog = ({ frontmatter, preamble, hot, footer, hasArchive }) => {
252
+ const cleanedPreamble = stripBlockquoteHistoryNotice(preamble);
253
+ const historyBlock = hasArchive
254
+ ? '## History\n\n> Older sessions are layered:\n>\n> - **7–30 days** → [`history/recent.md`](./history/recent.md) (full text)\n> - **>30 days** → [`history/condensed-index.md`](./history/condensed-index.md) (one-line TL;DRs that link into per-month `history/YYYY-MM.md` archives)'
255
+ : '';
256
+ const hotBlock = renderEntries(hot);
257
+ const parts = [
258
+ frontmatter,
259
+ '',
260
+ cleanedPreamble,
261
+ '',
262
+ historyBlock,
263
+ '',
264
+ '---',
265
+ '',
266
+ hotBlock,
267
+ '',
268
+ '---',
269
+ '',
270
+ footer || '',
271
+ '',
272
+ ];
273
+ return parts.filter((p) => p !== null && p !== undefined).join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
274
+ };
275
+
276
+ export const buildRecent = (entries, todayStr) => {
277
+ const frontmatter = FRONTMATTER('history', 3500, todayStr);
278
+ const preamble = `# Changelog WARM Archive — ${PROJECT_NAME}\n\n> Entries aged **7–30 days** from today. Newer → [\`../changelog.md\`](../changelog.md). Older → [\`condensed-index.md\`](./condensed-index.md) plus per-month \`YYYY-MM.md\` files.`;
279
+ const body = renderEntries(entries);
280
+ return `${frontmatter}\n${preamble}\n\n---\n\n${body}\n`;
281
+ };
282
+
283
+ export const buildCold = (year, month, entries, todayStr) => {
284
+ const frontmatter = FRONTMATTER('history', 1500, todayStr);
285
+ const preamble = `# Changelog COLD Archive — ${year}-${month}\n\n> Compressed entries from ${year}-${month} (older than 30 days). Cross-month one-liners → [\`condensed-index.md\`](./condensed-index.md). Full commit history: \`git log --since=${year}-${month}-01 --until=${year}-${month}-31\`.`;
286
+ const compressed = entries.map(compressEntry).join('\n\n---\n\n');
287
+ return `${frontmatter}\n${preamble}\n\n---\n\n${compressed}\n`;
288
+ };
289
+
290
+ export const buildCondensedIndex = (warmEntries, coldByMonth, todayStr) => {
291
+ const frontmatter = FRONTMATTER('history', 300, todayStr);
292
+ const intro = `# Condensed Index — ${PROJECT_NAME} Changelog\n\n> One-line TL;DR for every archived entry. Each line links to the file holding the full text.`;
293
+
294
+ const lines = [];
295
+ if (warmEntries.length > 0) {
296
+ lines.push('## WARM (7–30 days)\n');
297
+ for (const e of warmEntries) lines.push(summarizeEntry(e, 'recent.md'));
298
+ lines.push('');
299
+ }
300
+ const monthKeys = [...coldByMonth.keys()].sort().reverse();
301
+ for (const key of monthKeys) {
302
+ const [year, month] = key.split('-');
303
+ lines.push(`## COLD ${year}-${month}\n`);
304
+ for (const e of coldByMonth.get(key)) lines.push(summarizeEntry(e, `${year}-${month}.md`));
305
+ lines.push('');
306
+ }
307
+ return `${frontmatter}\n${intro}\n\n${lines.join('\n').trim()}\n`;
308
+ };
309
+
310
+ export const groupByMonth = (entries) => {
311
+ const map = new Map();
312
+ for (const e of entries) {
313
+ const key = `${e.year}-${e.month}`;
314
+ if (!map.has(key)) map.set(key, []);
315
+ map.get(key).push(e);
316
+ }
317
+ return map;
318
+ };
319
+
320
+ const main = async () => {
321
+ const { flags, opts } = parseArgs(process.argv);
322
+ const cutoffs = computeCutoffs(opts.today, opts.hotDays, opts.warmDays);
323
+ const todayStr = cutoffs.today.toISOString().slice(0, 10);
324
+
325
+ const changelogText = await readFile(CHANGELOG_PATH, 'utf8');
326
+ const parsed = parseChangelogText(changelogText);
327
+
328
+ // Pull in legacy archive file if present (one-time inhalation).
329
+ const legacyArchivePath = resolve(ROOT, 'docs/ai/changelog-archive.md');
330
+ let legacyEntries = [];
331
+ if (existsSync(legacyArchivePath)) {
332
+ const legacyText = await readFile(legacyArchivePath, 'utf8');
333
+ legacyEntries = parseChangelogText(legacyText).entries;
334
+ }
335
+
336
+ // Read existing archive files so rotation is idempotent and does not drop entries
337
+ // already in WARM/COLD when only HOT changed.
338
+ let warmExistingEntries = [];
339
+ if (existsSync(RECENT_PATH)) {
340
+ const recentText = await readFile(RECENT_PATH, 'utf8');
341
+ warmExistingEntries = parseChangelogText(recentText).entries;
342
+ }
343
+ let coldExistingEntries = [];
344
+ if (existsSync(HISTORY_DIR)) {
345
+ const archiveEntries = await readdir(HISTORY_DIR);
346
+ for (const name of archiveEntries) {
347
+ if (!/^\d{4}-\d{2}\.md$/.test(name)) continue;
348
+ const text = await readFile(resolve(HISTORY_DIR, name), 'utf8');
349
+ coldExistingEntries.push(...parseChangelogText(text).entries);
350
+ }
351
+ }
352
+
353
+ // Dedupe by (date + title) — favour the freshest occurrence by file source order.
354
+ const seen = new Set();
355
+ const allEntries = [
356
+ ...parsed.entries,
357
+ ...legacyEntries,
358
+ ...warmExistingEntries,
359
+ ...coldExistingEntries,
360
+ ]
361
+ .filter((e) => {
362
+ const key = `${e.dateStr}|${e.title}`;
363
+ if (seen.has(key)) return false;
364
+ seen.add(key);
365
+ return true;
366
+ })
367
+ .sort((a, b) => b.dateObj.getTime() - a.dateObj.getTime());
368
+ const { hot, warm, cold } = categorize(allEntries, cutoffs);
369
+ const coldByMonth = groupByMonth(cold);
370
+
371
+ const summary = {
372
+ today: todayStr,
373
+ hotCutoff: cutoffs.hotCutoff.toISOString().slice(0, 10),
374
+ warmCutoff: cutoffs.warmCutoff.toISOString().slice(0, 10),
375
+ totals: { all: allEntries.length, hot: hot.length, warm: warm.length, cold: cold.length },
376
+ hotDates: hot.map((e) => e.dateStr),
377
+ warmDates: warm.map((e) => e.dateStr),
378
+ coldDates: cold.map((e) => e.dateStr),
379
+ coldFiles: [...coldByMonth.keys()].sort(),
380
+ };
381
+
382
+ if (flags.check) {
383
+ const tooOldInHot = parsed.entries.filter((e) => e.dateObj < cutoffs.hotCutoff);
384
+ if (tooOldInHot.length > 0) {
385
+ console.error(
386
+ `[archive-changelog] FAIL: ${tooOldInHot.length} entries in changelog.md are older than ${opts.hotDays} days (relative to ${todayStr}).`,
387
+ );
388
+ for (const e of tooOldInHot) console.error(` - ${e.dateStr} — ${e.title}`);
389
+ console.error('Run the changelog archive script (without --check) to rotate.');
390
+ process.exit(1);
391
+ }
392
+ console.log(`[archive-changelog] OK — all changelog.md entries are within ${opts.hotDays} days of ${todayStr}.`);
393
+ process.exit(0);
394
+ }
395
+
396
+ if (flags.dryRun) {
397
+ console.log('[archive-changelog] DRY-RUN — no files will be changed.');
398
+ console.log(JSON.stringify(summary, null, 2));
399
+ return;
400
+ }
401
+
402
+ await mkdir(HISTORY_DIR, { recursive: true });
403
+
404
+ const newChangelog = buildChangelog({
405
+ frontmatter: parsed.frontmatter || FRONTMATTER('history', 700, todayStr),
406
+ preamble: parsed.preamble,
407
+ hot,
408
+ footer: parsed.footer,
409
+ hasArchive: warm.length > 0 || cold.length > 0,
410
+ });
411
+ await writeFile(CHANGELOG_PATH, newChangelog, 'utf8');
412
+
413
+ if (warm.length > 0) {
414
+ await writeFile(RECENT_PATH, buildRecent(warm, todayStr), 'utf8');
415
+ }
416
+
417
+ for (const [key, entries] of coldByMonth) {
418
+ const [year, month] = key.split('-');
419
+ const path = resolve(HISTORY_DIR, `${year}-${month}.md`);
420
+ await writeFile(path, buildCold(year, month, entries, todayStr), 'utf8');
421
+ }
422
+
423
+ if (warm.length > 0 || cold.length > 0) {
424
+ await writeFile(INDEX_PATH, buildCondensedIndex(warm, coldByMonth, todayStr), 'utf8');
425
+ }
426
+
427
+ console.log('[archive-changelog] migrated:');
428
+ console.log(` HOT (${relative(ROOT, CHANGELOG_PATH)}): ${hot.length}`);
429
+ console.log(` WARM (${relative(ROOT, RECENT_PATH)}): ${warm.length}`);
430
+ for (const key of coldByMonth.keys()) {
431
+ console.log(` COLD (history/${key}.md): ${coldByMonth.get(key).length}`);
432
+ }
433
+ };
434
+
435
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
436
+ if (isDirectRun) {
437
+ main().catch((err) => {
438
+ console.error(err);
439
+ process.exit(1);
440
+ });
441
+ }
@@ -0,0 +1,212 @@
1
+ import { describe, it } from 'node:test';
2
+ import { expect } from './_expect-shim.mjs';
3
+ import {
4
+ parseChangelogText,
5
+ stripTrailingSeparator,
6
+ computeCutoffs,
7
+ categorize,
8
+ compressEntry,
9
+ buildChangelog,
10
+ buildRecent,
11
+ buildCold,
12
+ buildCondensedIndex,
13
+ groupByMonth,
14
+ } from './archive-changelog.mjs';
15
+
16
+ const FM = '---\ntype: history\nlastUpdated: 2026-05-24\nmaxLines: 700\n---\n';
17
+
18
+ const makeEntry = (dateStr, title = '') => {
19
+ const [year, month, day] = dateStr.split('.');
20
+ return {
21
+ dateStr,
22
+ dateObj: new Date(`${year}-${month}-${day}T00:00:00Z`),
23
+ year,
24
+ month,
25
+ day,
26
+ title,
27
+ block: `## ${dateStr} — ${title}\n\n**Goal:** test body.\n\n**Files:**\n- a.ts`,
28
+ };
29
+ };
30
+
31
+ describe('parseChangelogText', () => {
32
+ it('extracts frontmatter, preamble, entries, and trailing footer', () => {
33
+ const text = `${FM}\n# Changelog\n\n## 2026.05.20 — alpha\n\nbody one.\n\n## 2026.05.10 — beta\n\nbody two.\n\n## Footer\n\nstray.\n`;
34
+ const parsed = parseChangelogText(text);
35
+ expect(parsed.frontmatter).toBe(FM);
36
+ expect(parsed.preamble).toContain('# Changelog');
37
+ expect(parsed.entries).toHaveLength(2);
38
+ expect(parsed.entries[0].dateStr).toBe('2026.05.20');
39
+ expect(parsed.entries[0].title).toBe('alpha');
40
+ expect(parsed.footer).toContain('## Footer');
41
+ });
42
+
43
+ it('does NOT slurp preamble `## History` into footer when it appears before any entry (preamble-before-entries regression)', () => {
44
+ const text = `${FM}\n# Changelog\n\n## History\n\n> See history/recent.md.\n\n---\n\n## 2026.05.20 — alpha\n\nbody.\n`;
45
+ const parsed = parseChangelogText(text);
46
+ expect(parsed.entries).toHaveLength(1);
47
+ expect(parsed.entries[0].dateStr).toBe('2026.05.20');
48
+ expect(parsed.footer).toBe('');
49
+ });
50
+
51
+ it('returns empty entries when body has no date headings', () => {
52
+ const text = `${FM}\n# Just preamble, no entries.\n`;
53
+ const parsed = parseChangelogText(text);
54
+ expect(parsed.entries).toEqual([]);
55
+ expect(parsed.preamble).toContain('# Just preamble');
56
+ });
57
+
58
+ it('strips trailing separator + "Last Updated" footer line from each entry block', () => {
59
+ const text = `${FM}\n## 2026.05.20 — alpha\n\nbody.\n\n**Last Updated:** 2026.05.20\n\n---\n\n## 2026.05.10 — beta\n\nlater.\n`;
60
+ const parsed = parseChangelogText(text);
61
+ expect(parsed.entries[0].block).not.toMatch(/Last Updated/);
62
+ expect(parsed.entries[0].block).not.toMatch(/---\s*$/);
63
+ });
64
+ });
65
+
66
+ describe('stripTrailingSeparator', () => {
67
+ it('strips trailing `---`, blanks, and Last-Updated lines', () => {
68
+ const input = 'body line\n\n---\n\n**Last Updated:** 2026.05.20\n\n---\n';
69
+ expect(stripTrailingSeparator(input)).toBe('body line');
70
+ });
71
+
72
+ it('returns block unchanged when nothing trailing matches', () => {
73
+ expect(stripTrailingSeparator('keep this exact line')).toBe('keep this exact line');
74
+ });
75
+ });
76
+
77
+ describe('computeCutoffs', () => {
78
+ it('returns HOT/WARM cutoffs computed from todayStr (inclusive window)', () => {
79
+ const { today, hotCutoff, warmCutoff } = computeCutoffs('2026-05-24', 3, 30);
80
+ expect(today.toISOString().slice(0, 10)).toBe('2026-05-24');
81
+ expect(hotCutoff.toISOString().slice(0, 10)).toBe('2026-05-22'); // 24 - 2
82
+ expect(warmCutoff.toISOString().slice(0, 10)).toBe('2026-04-25'); // 24 - 29
83
+ });
84
+ });
85
+
86
+ describe('categorize', () => {
87
+ it('partitions entries by HOT / WARM / COLD windows', () => {
88
+ const cutoffs = computeCutoffs('2026-05-24', 3, 30);
89
+ const entries = [
90
+ makeEntry('2026.05.23'), // HOT
91
+ makeEntry('2026.05.10'), // WARM
92
+ makeEntry('2026.03.01'), // COLD
93
+ ];
94
+ const { hot, warm, cold } = categorize(entries, cutoffs);
95
+ expect(hot.map((e) => e.dateStr)).toEqual(['2026.05.23']);
96
+ expect(warm.map((e) => e.dateStr)).toEqual(['2026.05.10']);
97
+ expect(cold.map((e) => e.dateStr)).toEqual(['2026.03.01']);
98
+ });
99
+ });
100
+
101
+ describe('compressEntry', () => {
102
+ it('keeps heading, a summary paragraph, file bullets, and metrics', () => {
103
+ const entry = makeEntry('2026.05.20', 'compressor smoke');
104
+ entry.block = `## 2026.05.20 — compressor smoke
105
+
106
+ **Goal:** verify compressor output shape.
107
+
108
+ **Files:**
109
+ - a.ts
110
+ - b.ts
111
+
112
+ **Result:** 8 passed, 0 failed.`;
113
+ const out = compressEntry(entry);
114
+ expect(out).toMatch(/^## 2026\.05\.20/);
115
+ expect(out).toMatch(/\*\*Goal:\*\*/);
116
+ expect(out).toMatch(/\*\*Files:\*\*/);
117
+ expect(out).toMatch(/8 passed/);
118
+ });
119
+
120
+ it('falls back to first non-heading paragraph when no labelled paragraph present', () => {
121
+ const entry = makeEntry('2026.05.20', 'unlabelled');
122
+ entry.block = `## 2026.05.20 — unlabelled\n\njust a plain summary.`;
123
+ const out = compressEntry(entry);
124
+ expect(out).toMatch(/just a plain summary/);
125
+ });
126
+ });
127
+
128
+ describe('buildChangelog', () => {
129
+ it('emits frontmatter, cleaned preamble, History pointer, and HOT block when hasArchive', () => {
130
+ const result = buildChangelog({
131
+ frontmatter: FM,
132
+ preamble: '# Changelog',
133
+ hot: [makeEntry('2026.05.23', 'recent')],
134
+ footer: '',
135
+ hasArchive: true,
136
+ });
137
+ expect(result).toMatch(/^---\n/);
138
+ expect(result).toMatch(/# Changelog/);
139
+ expect(result).toMatch(/## History/);
140
+ expect(result).toMatch(/## 2026\.05\.23 — recent/);
141
+ });
142
+
143
+ it('omits History pointer when hasArchive=false', () => {
144
+ const result = buildChangelog({
145
+ frontmatter: FM,
146
+ preamble: '# Changelog',
147
+ hot: [makeEntry('2026.05.23', 'recent')],
148
+ footer: '',
149
+ hasArchive: false,
150
+ });
151
+ expect(result).not.toMatch(/## History/);
152
+ });
153
+ });
154
+
155
+ describe('buildRecent', () => {
156
+ it('emits frontmatter with maxLines 3500 for WARM archive', () => {
157
+ const result = buildRecent([makeEntry('2026.05.10', 'warm')], '2026-05-24');
158
+ expect(result).toMatch(/maxLines: 3500/);
159
+ expect(result).toMatch(/Changelog WARM Archive/);
160
+ expect(result).toMatch(/## 2026\.05\.10/);
161
+ });
162
+ });
163
+
164
+ describe('buildCold', () => {
165
+ it('emits compressed monthly archive with frontmatter and preamble', () => {
166
+ const entries = [makeEntry('2026.03.10', 'cold one')];
167
+ const result = buildCold('2026', '03', entries, '2026-05-24');
168
+ expect(result).toMatch(/Changelog COLD Archive — 2026-03/);
169
+ expect(result).toMatch(/## 2026\.03\.10 — cold one/);
170
+ });
171
+ });
172
+
173
+ describe('buildCondensedIndex', () => {
174
+ it('lists WARM then per-month COLD with one-line summaries', () => {
175
+ const warm = [makeEntry('2026.05.10', 'warm')];
176
+ const coldByMonth = new Map([['2026-03', [makeEntry('2026.03.05', 'cold')]]]);
177
+ const result = buildCondensedIndex(warm, coldByMonth, '2026-05-24');
178
+ expect(result).toMatch(/## WARM \(7–30 days\)/);
179
+ expect(result).toMatch(/## COLD 2026-03/);
180
+ expect(result).toMatch(/\[recent\.md\]/);
181
+ expect(result).toMatch(/\[2026-03\.md\]/);
182
+ });
183
+ });
184
+
185
+ describe('groupByMonth', () => {
186
+ it('keys entries by YYYY-MM', () => {
187
+ const grouped = groupByMonth([
188
+ makeEntry('2026.03.10'),
189
+ makeEntry('2026.03.20'),
190
+ makeEntry('2026.04.01'),
191
+ ]);
192
+ expect([...grouped.keys()].sort()).toEqual(['2026-03', '2026-04']);
193
+ expect(grouped.get('2026-03')).toHaveLength(2);
194
+ });
195
+ });
196
+
197
+ describe('idempotency contract', () => {
198
+ it('parse → buildChangelog → parse yields identical entries (idempotency regression)', () => {
199
+ 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`;
200
+ const first = parseChangelogText(text);
201
+ const rebuilt = buildChangelog({
202
+ frontmatter: first.frontmatter,
203
+ preamble: first.preamble,
204
+ hot: first.entries,
205
+ footer: first.footer,
206
+ hasArchive: true,
207
+ });
208
+ const second = parseChangelogText(rebuilt);
209
+ expect(second.entries.map((e) => e.dateStr)).toEqual(first.entries.map((e) => e.dateStr));
210
+ expect(second.entries.map((e) => e.title)).toEqual(first.entries.map((e) => e.title));
211
+ });
212
+ });