@sabaiway/agent-workflow-memory 1.12.0 → 2.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 +52 -1
- package/README.md +2 -2
- package/SKILL.md +35 -21
- package/bin/install.mjs +32 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +705 -320
- package/references/scripts/archive-decisions.test.mjs +652 -312
- package/references/scripts/check-docs-size.mjs +35 -5
- package/references/scripts/check-docs-size.test.mjs +55 -0
- package/references/templates/adr/log.md +25 -0
- package/references/templates/adr-record.md +72 -0
- package/references/templates/decisions.md +7 -18
- package/scripts/stamp-takeover.mjs +2 -2
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { describe, it, afterEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
HOT_REL,
|
|
8
8
|
WARM_REL,
|
|
9
9
|
COLD_REL,
|
|
10
|
+
ADR_DIR_REL,
|
|
11
|
+
NAV_REL,
|
|
12
|
+
HEADING_RE,
|
|
13
|
+
RECORD_CAP,
|
|
10
14
|
parseDecisionsText,
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
slugify,
|
|
16
|
+
recordFileName,
|
|
17
|
+
explode,
|
|
18
|
+
blockHash,
|
|
19
|
+
verifyConservation,
|
|
20
|
+
computeSupersededSet,
|
|
21
|
+
buildNavigator,
|
|
22
|
+
loadAdrStore,
|
|
13
23
|
lineCountOf,
|
|
14
|
-
planRotation,
|
|
15
|
-
updateRangeTokens,
|
|
16
24
|
runCli,
|
|
17
25
|
defaultRegenerateIndex,
|
|
18
26
|
} from './archive-decisions.mjs';
|
|
19
27
|
|
|
20
|
-
// Hermetic
|
|
21
|
-
//
|
|
28
|
+
// Hermetic: this test ships as deploy payload and runs inside CONSUMER repos via the pre-commit
|
|
29
|
+
// `node --test scripts/*.test.mjs` — it must never read the host repo's docs/ai. Every fixture lives
|
|
30
|
+
// in a fresh temp root; the index-regen hook + the git-dir snapshot are always injected.
|
|
22
31
|
|
|
23
32
|
const tempDirs = [];
|
|
24
33
|
const makeRoot = () => {
|
|
@@ -33,74 +42,123 @@ afterEach(() => {
|
|
|
33
42
|
const fm = (cap) =>
|
|
34
43
|
`---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: ${cap}\n---\n`;
|
|
35
44
|
|
|
36
|
-
// One canonical
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
// One canonical ADR block. `status: null` omits the status line (the 6-of-9-active default case);
|
|
46
|
+
// `separate: true` writes Date and Status on their own lines (the AD-001/AD-043 shape).
|
|
47
|
+
const adrBlock = (id, { title = `Decision ${id}`, date = '2026-01-01', status = 'Accepted', body = 2, separate = false } = {}) => {
|
|
48
|
+
const lines = [`## AD-${id} — ${title}`, ''];
|
|
49
|
+
if (separate) {
|
|
50
|
+
if (date) lines.push(`**Date:** ${date}`);
|
|
51
|
+
if (status) lines.push(`**Status:** ${status}`);
|
|
52
|
+
} else if (date || status) {
|
|
53
|
+
lines.push(`**Date:** ${date}${status ? ` · **Status:** ${status}` : ''}`);
|
|
54
|
+
}
|
|
55
|
+
lines.push('');
|
|
56
|
+
for (let i = 0; i < body; i += 1) lines.push(`body ${i + 1} of AD-${id}`);
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
};
|
|
39
59
|
|
|
40
60
|
const tierText = (cap, preamble, blocks) => `${fm(cap)}\n${preamble}\n\n${blocks.join('\n\n')}\n`;
|
|
41
61
|
|
|
42
62
|
const HOT_PREAMBLE = [
|
|
43
63
|
'# Architecture Decision Records (ADRs)',
|
|
44
64
|
'',
|
|
45
|
-
'> Newest at the bottom.',
|
|
65
|
+
'> Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.',
|
|
46
66
|
'>',
|
|
47
|
-
'> **Archive:** the stable
|
|
67
|
+
'> **Archive:** the stable **AD-003 … AD-004** now live in [`history/decisions-archive.md`](./history/decisions-archive.md) (the earliest **AD-001 … AD-002** rolled further to the COLD [`history/decisions-archive-early.md`](./history/decisions-archive-early.md)); this file carries the active set (AD-005 onward).',
|
|
48
68
|
].join('\n');
|
|
49
|
-
|
|
50
|
-
const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier. The earliest (AD-001 … AD-002) are COLD.';
|
|
69
|
+
const WARM_PREAMBLE = '# ADR Archive (AD-003 … AD-004)\n\n> WARM tier.';
|
|
51
70
|
const COLD_PREAMBLE = '# ADR Early Archive (AD-001 … AD-002)\n\n> COLD tier.';
|
|
52
71
|
|
|
53
|
-
// Seed a
|
|
54
|
-
|
|
55
|
-
const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCapDelta = 100, coldCapDelta = 100 }) => {
|
|
72
|
+
// Seed a legacy 3-tier tree (pre-migration). Caps generous so nothing overflows unless asked.
|
|
73
|
+
const seedLegacy = (root, { hot, warm = [], cold = [], hotCapDelta = 200 }) => {
|
|
56
74
|
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const probe = tierText(999, preamble, blocks);
|
|
75
|
+
const writeTier = (rel, preamble, blocks, capDelta) => {
|
|
76
|
+
const probe = tierText(9999, preamble, blocks);
|
|
60
77
|
const cap = lineCountOf(probe) + capDelta;
|
|
61
78
|
writeFileSync(join(root, rel), tierText(cap, preamble, blocks));
|
|
62
79
|
return cap;
|
|
63
80
|
};
|
|
81
|
+
const hotBlocks = hot.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
|
|
82
|
+
const warmBlocks = warm.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
|
|
83
|
+
const coldBlocks = cold.map((spec) => (typeof spec === 'string' ? adrBlock(spec) : adrBlock(spec.id, spec)));
|
|
64
84
|
return {
|
|
65
|
-
hotCap:
|
|
66
|
-
warmCap:
|
|
67
|
-
coldCap:
|
|
85
|
+
hotCap: writeTier(HOT_REL, HOT_PREAMBLE, hotBlocks, hotCapDelta),
|
|
86
|
+
warmCap: warm.length ? writeTier(WARM_REL, WARM_PREAMBLE, warmBlocks, 200) : null,
|
|
87
|
+
coldCap: cold.length ? writeTier(COLD_REL, COLD_PREAMBLE, coldBlocks, 200) : null,
|
|
68
88
|
};
|
|
69
89
|
};
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const run = (argv, root) => {
|
|
91
|
+
const fakeGit = (root) => (cmd) => (cmd === 'git' ? { status: 0, stdout: `${join(root, '.git')}\n` } : { status: 1 });
|
|
92
|
+
const noGit = () => ({ status: 1 });
|
|
93
|
+
|
|
94
|
+
const run = (argv, root, opts = {}) => {
|
|
76
95
|
const out = [];
|
|
77
96
|
const err = [];
|
|
78
|
-
const
|
|
79
|
-
|
|
97
|
+
const calls = [];
|
|
98
|
+
const regen = opts.regen ?? ((r, t) => { calls.push([r, t]); return { ok: true, detail: '' }; });
|
|
99
|
+
const code = runCli(argv, {
|
|
100
|
+
root,
|
|
101
|
+
log: (l) => out.push(l),
|
|
102
|
+
logError: (l) => err.push(l),
|
|
103
|
+
regenerateIndex: regen,
|
|
104
|
+
stamp: opts.stamp ?? 'STAMP',
|
|
105
|
+
snapshotFallbackBase: opts.fallbackBase,
|
|
106
|
+
spawnSync: opts.spawnSync ?? fakeGit(root),
|
|
107
|
+
});
|
|
108
|
+
return { code, out, err, text: out.join('\n'), errText: err.join('\n'), regenCalls: calls };
|
|
80
109
|
};
|
|
81
110
|
|
|
111
|
+
const adrFiles = (root) => (existsSync(join(root, ADR_DIR_REL)) ? readdirSync(join(root, ADR_DIR_REL)).filter((n) => /^AD-\d{3,}-/.test(n)).sort() : []);
|
|
82
112
|
const idsIn = (root, rel) => parseDecisionsText(readFileSync(join(root, rel), 'utf8'), rel).entries.map((e) => e.id);
|
|
83
113
|
|
|
84
|
-
// ──
|
|
114
|
+
// ── 1.1 — the widened grammar + real-corpus parser + status/date/lifecycle extraction ──
|
|
115
|
+
|
|
116
|
+
describe('1.1 parser — real-corpus formats, widened grammar, verbatim blocks', () => {
|
|
117
|
+
it('parses AD-001 (separate Date/Status lines), a same-line + wrapped-rich status, and AD-1000', () => {
|
|
118
|
+
const wrapped = ['## AD-042 — Same-line rich', '', '**Date:** 2026-07-04 · **Status:** Accepted (ships kit `1.34.0`;', 'wrapped continuation of the status prose)', '', 'body'].join('\n');
|
|
119
|
+
const text = tierText(500, '# T', [
|
|
120
|
+
adrBlock('001', { separate: true }),
|
|
121
|
+
wrapped,
|
|
122
|
+
adrBlock('1000', { title: 'Four digits' }),
|
|
123
|
+
]);
|
|
124
|
+
const p = parseDecisionsText(text, 'x');
|
|
125
|
+
assert.deepEqual(p.entries.map((e) => e.id), ['001', '042', '1000']);
|
|
126
|
+
assert.equal(p.entries[1].status, 'accepted', 'the leading Status word wins even when the prose wraps');
|
|
127
|
+
assert.match(p.entries[1].block, /wrapped continuation of the status prose/, 'the block is preserved VERBATIM');
|
|
128
|
+
assert.equal(p.entries[0].date, '2026-01-01');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('a MISSING status line defaults to accepted (6 of 9 active HOT ADRs carry none)', () => {
|
|
132
|
+
const noStatus = ['## AD-045 — No status line', '', '**Problem.** starts straight in.', '', 'body'].join('\n');
|
|
133
|
+
const p = parseDecisionsText(tierText(500, '# T', [noStatus]), 'x');
|
|
134
|
+
assert.equal(p.entries[0].status, 'accepted');
|
|
135
|
+
assert.equal(p.entries[0].date, null, 'no Date line → null');
|
|
136
|
+
});
|
|
85
137
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
138
|
+
it('backfills supersedes / supersededBy from the real corpus link forms', () => {
|
|
139
|
+
const blocks = [
|
|
140
|
+
adrBlock('006', { status: 'Amended by [[AD-014]] (later refinement)' }),
|
|
141
|
+
adrBlock('007', { status: 'Superseded by [[AD-011]] (Plan B realized)' }),
|
|
142
|
+
adrBlock('018', { status: 'Accepted — realized in kit 1.7.0. Supersedes [[AD-007]].' }),
|
|
143
|
+
];
|
|
144
|
+
const p = parseDecisionsText(tierText(500, '# T', blocks), 'x');
|
|
145
|
+
assert.deepEqual(p.entries[0].supersededBy, ['014']);
|
|
146
|
+
assert.equal(p.entries[0].status, 'amended');
|
|
147
|
+
assert.deepEqual(p.entries[1].supersededBy, ['011']);
|
|
148
|
+
assert.equal(p.entries[1].status, 'superseded');
|
|
149
|
+
assert.deepEqual(p.entries[2].supersedes, ['007']);
|
|
150
|
+
assert.equal(p.entries[2].status, 'accepted');
|
|
93
151
|
});
|
|
94
152
|
|
|
95
153
|
const badHeadings = [
|
|
96
154
|
['a hyphen instead of the em-dash', '## AD-024 - Title'],
|
|
97
|
-
['a 2-digit id', '## AD-24 — Title'],
|
|
155
|
+
['a 2-digit id (below the AD-\\d{3,} floor)', '## AD-24 — Title'],
|
|
98
156
|
['a missing title', '## AD-024 — '],
|
|
99
157
|
['an unrelated H2', '## Notes'],
|
|
100
158
|
];
|
|
101
159
|
for (const [name, heading] of badHeadings) {
|
|
102
160
|
it(`rejects ${name} naming file:line — never a silent merge`, () => {
|
|
103
|
-
const text = `${fm(500)}\n# T\n\n${
|
|
161
|
+
const text = `${fm(500)}\n# T\n\n${adrBlock('001')}\n\n${heading}\nbody\n`;
|
|
104
162
|
assert.throws(() => parseDecisionsText(text, 'docs/ai/decisions.md'), (e) => {
|
|
105
163
|
assert.equal(e.exitCode, 1);
|
|
106
164
|
assert.match(e.message, /docs\/ai\/decisions\.md:\d+/);
|
|
@@ -110,373 +168,655 @@ describe('parseDecisionsText — fail-loud on non-canonical headings', () => {
|
|
|
110
168
|
});
|
|
111
169
|
}
|
|
112
170
|
|
|
113
|
-
it('rejects disordered ids (oldest
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
it('rejects disordered ids (oldest at the top, NUMERIC order)', () => {
|
|
172
|
+
assert.throws(() => parseDecisionsText(tierText(500, '# T', [adrBlock('007'), adrBlock('005')]), 'x'), /strictly ascending/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('AD-200 precedes AD-1000 numerically (never lexically — Decision 10)', () => {
|
|
176
|
+
const ok = parseDecisionsText(tierText(500, '# T', [adrBlock('200'), adrBlock('1000')]), 'x');
|
|
177
|
+
assert.deepEqual(ok.entries.map((e) => e.id), ['200', '1000']);
|
|
178
|
+
assert.throws(() => parseDecisionsText(tierText(500, '# T', [adrBlock('1000'), adrBlock('200')]), 'x'), /strictly ascending/, 'AD-1000 before AD-200 is descending numerically');
|
|
116
179
|
});
|
|
117
180
|
});
|
|
118
181
|
|
|
119
|
-
describe('
|
|
120
|
-
it('
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
assert.
|
|
182
|
+
describe('slugify + recordFileName', () => {
|
|
183
|
+
it('lowercases, replaces non-alphanumerics, trims to a bounded length', () => {
|
|
184
|
+
assert.equal(slugify('Host-level bridge settings surface'), 'host-level-bridge-settings-surface');
|
|
185
|
+
assert.equal(slugify('Onboarding UX: batched asks (the seeding↔hook chain)'), 'onboarding-ux-batched-asks-the-seeding-hook-chain');
|
|
186
|
+
assert.ok(slugify('x'.repeat(200)).length <= 60);
|
|
187
|
+
});
|
|
188
|
+
it('the filename encodes the id (the O(1) by-id glob key)', () => {
|
|
189
|
+
assert.equal(recordFileName('042', 'a-slug'), 'AD-042-a-slug.md');
|
|
124
190
|
});
|
|
125
191
|
});
|
|
126
192
|
|
|
127
|
-
// ──
|
|
193
|
+
// ── 1.2 — explode + conservation (pure) ────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe('1.2 explode — one immutable record per id, verbatim block, lifecycle frontmatter', () => {
|
|
196
|
+
it('builds a record per entry with inline lifecycle frontmatter and the verbatim block', () => {
|
|
197
|
+
const p = parseDecisionsText(tierText(500, '# T', [adrBlock('003', { status: 'Superseded by [[AD-009]]' })]), 'x');
|
|
198
|
+
const [rec] = explode(p.entries, '2026-07-09');
|
|
199
|
+
assert.equal(rec.fileName, 'AD-003-decision-003.md');
|
|
200
|
+
assert.match(rec.frontmatter, /type: adr/);
|
|
201
|
+
assert.match(rec.frontmatter, new RegExp(`maxLines: ${RECORD_CAP}`));
|
|
202
|
+
assert.match(rec.frontmatter, /status: superseded/);
|
|
203
|
+
assert.match(rec.frontmatter, /supersededBy: \[AD-009\]/);
|
|
204
|
+
assert.equal(rec.block, p.entries[0].block, 'the block is carried VERBATIM');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('1.2 verifyConservation — partition-preserving, extra-aware, fail-loud', () => {
|
|
209
|
+
const items = (pairs) => pairs.map(([id, block]) => ({ id, block }));
|
|
210
|
+
it('passes when the NEW side is exactly the OLD multiset', () => {
|
|
211
|
+
const old = items([['001', 'a'], ['002', 'b']]);
|
|
212
|
+
assert.doesNotThrow(() => verifyConservation(old, items([['001', 'a'], ['002', 'b']])));
|
|
213
|
+
});
|
|
214
|
+
it('fails on a DROPPED id (an ADR would be lost)', () => {
|
|
215
|
+
assert.throws(() => verifyConservation(items([['001', 'a'], ['002', 'b']]), items([['001', 'a']])), /absent from the migrated store/);
|
|
216
|
+
});
|
|
217
|
+
it('fails on an EDITED block (hash mismatch — the block must move verbatim)', () => {
|
|
218
|
+
assert.throws(() => verifyConservation(items([['001', 'a']]), items([['001', 'a-edited']])), /block changed during migration/);
|
|
219
|
+
});
|
|
220
|
+
it('fails on a STRAY new id absent from the OLD tiers (invented history)', () => {
|
|
221
|
+
assert.throws(() => verifyConservation(items([['001', 'a']]), items([['001', 'a'], ['009', 'x']])), /stray\/invented/);
|
|
222
|
+
});
|
|
223
|
+
it('fails on a RENUMBER (old 002 lost, new 003 stray)', () => {
|
|
224
|
+
assert.throws(() => verifyConservation(items([['001', 'a'], ['002', 'b']]), items([['001', 'a'], ['003', 'b']])), /lost|absent from the migrated store/);
|
|
225
|
+
});
|
|
226
|
+
it('blockHash is stable + content-addressed', () => {
|
|
227
|
+
assert.equal(blockHash('x'), blockHash('x'));
|
|
228
|
+
assert.notEqual(blockHash('x'), blockHash('y'));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── 1.3 — --migrate + snapshot + retire monoliths + idempotence + legacy guard ─────────
|
|
128
233
|
|
|
129
|
-
describe('
|
|
130
|
-
it('
|
|
234
|
+
describe('1.3 --migrate dry-run writes nothing', () => {
|
|
235
|
+
it('prints the file set + conservation proof, mutates no file, creates no adr/ tree', () => {
|
|
131
236
|
const root = makeRoot();
|
|
132
|
-
|
|
133
|
-
const before =
|
|
134
|
-
const
|
|
237
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
238
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
239
|
+
const { code, text } = run(['--migrate', '--today=2026-07-09'], root);
|
|
240
|
+
assert.equal(code, 0, text);
|
|
241
|
+
assert.match(text, /DRY-RUN/);
|
|
242
|
+
assert.match(text, /conserved/);
|
|
243
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
244
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
|
|
245
|
+
assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no adr/ tree created on a dry-run');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
135
248
|
|
|
136
|
-
|
|
249
|
+
describe('1.3 --migrate --apply — records + snapshot + retire monoliths + HOT rewrite', () => {
|
|
250
|
+
it('writes one record per archived id, a git-dir snapshot, a nav, retires monoliths, rewrites the HOT preamble', () => {
|
|
251
|
+
const root = makeRoot();
|
|
252
|
+
seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
253
|
+
const { code, text } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
137
254
|
assert.equal(code, 0, text);
|
|
138
|
-
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'the oldest HOT entry rolled');
|
|
139
|
-
assert.deepEqual(idsIn(root, WARM_REL), ['003', '004', '005'], 'appended to the WARM end');
|
|
140
|
-
assert.deepEqual(idsIn(root, COLD_REL), ['001', '002'], 'COLD untouched');
|
|
141
255
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
assert.
|
|
145
|
-
assert.ok(
|
|
256
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md']);
|
|
257
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['005', '006', '007', '008'], 'HOT keeps the active window');
|
|
258
|
+
assert.ok(!existsSync(join(root, WARM_REL)) && !existsSync(join(root, COLD_REL)), 'both monoliths retired');
|
|
259
|
+
assert.ok(existsSync(join(root, NAV_REL)), 'navigator generated');
|
|
260
|
+
|
|
261
|
+
const hotText = readFileSync(join(root, HOT_REL), 'utf8');
|
|
262
|
+
assert.match(hotText, /adr\/log\.md/, 'the HOT preamble now points at the navigator');
|
|
263
|
+
assert.doesNotMatch(hotText, /decisions-archive/, 'the dead monolith links are dropped');
|
|
264
|
+
assert.match(hotText, /AD-005 onward/, 'the active-window token is rewritten to the retained oldest');
|
|
265
|
+
|
|
266
|
+
const snapDir = join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP');
|
|
267
|
+
assert.ok(existsSync(snapDir), 'the snapshot landed under the git dir (never the work tree)');
|
|
268
|
+
assert.equal(readdirSync(snapDir).sort().length, 3, 'snapshot captured decisions.md + both monoliths');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('the snapshot captures the ORIGINAL decisions.md + monolith BYTES (recoverable content, not just names)', () => {
|
|
272
|
+
const root = makeRoot();
|
|
273
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
|
|
274
|
+
const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
|
|
275
|
+
const warmBefore = readFileSync(join(root, WARM_REL), 'utf8');
|
|
276
|
+
const coldBefore = readFileSync(join(root, COLD_REL), 'utf8');
|
|
277
|
+
run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
278
|
+
const snapDir = join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP');
|
|
279
|
+
assert.equal(readFileSync(join(snapDir, 'docs__ai__decisions.md'), 'utf8'), hotBefore, 'the ORIGINAL HOT is recoverable verbatim');
|
|
280
|
+
assert.equal(readFileSync(join(snapDir, 'docs__ai__history__decisions-archive.md'), 'utf8'), warmBefore, 'the ORIGINAL WARM monolith is recoverable verbatim');
|
|
281
|
+
assert.equal(readFileSync(join(snapDir, 'docs__ai__history__decisions-archive-early.md'), 'utf8'), coldBefore, 'the ORIGINAL COLD monolith is recoverable verbatim');
|
|
146
282
|
});
|
|
147
283
|
|
|
148
|
-
it('
|
|
284
|
+
it('migrating an OVER-CAP HOT explodes the HOT overflow into records too (not only the monoliths)', () => {
|
|
149
285
|
const root = makeRoot();
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const { code, text } = run([], root);
|
|
286
|
+
seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003'], cold: ['001'], hotCapDelta: -1 });
|
|
287
|
+
const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
153
288
|
assert.equal(code, 0);
|
|
154
|
-
assert.
|
|
155
|
-
assert.
|
|
289
|
+
assert.ok(adrFiles(root).includes('AD-005-decision-005.md'), 'the HOT overflow AD-005 exploded to a record');
|
|
290
|
+
assert.ok(!idsIn(root, HOT_REL).includes('005'), 'AD-005 left the HOT window');
|
|
291
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the migrated over-cap tree is green');
|
|
156
292
|
});
|
|
157
|
-
});
|
|
158
293
|
|
|
159
|
-
|
|
160
|
-
it('an incoming HOT entry that would overflow WARM pushes the oldest WARM entry to COLD', () => {
|
|
294
|
+
it('a second --apply no-ops (monoliths already retired → already-migrated)', () => {
|
|
161
295
|
const root = makeRoot();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
hotCapDelta: -1, // force one HOT roll
|
|
167
|
-
warmCapDelta: 3, // the incoming 6-line append does NOT fit → chain
|
|
168
|
-
});
|
|
169
|
-
const { code } = run(['--today=2026-01-02'], root);
|
|
296
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
|
|
297
|
+
run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
298
|
+
const snapshotBefore = readdirSync(join(root, '.git'));
|
|
299
|
+
const { code, text } = run(['--migrate', '--apply', '--today=2026-07-10'], root);
|
|
170
300
|
assert.equal(code, 0);
|
|
171
|
-
assert.
|
|
172
|
-
assert.deepEqual(
|
|
173
|
-
assert.deepEqual(idsIn(root, COLD_REL), ['001', '002', '003'], 'the chained entry landed in COLD');
|
|
301
|
+
assert.match(text, /already migrated/);
|
|
302
|
+
assert.deepEqual(readdirSync(join(root, '.git')), snapshotBefore, 'no second snapshot');
|
|
174
303
|
});
|
|
175
|
-
});
|
|
176
304
|
|
|
177
|
-
|
|
178
|
-
it('a roll that does not fit COLD headroom leaves all three files byte-identical', () => {
|
|
305
|
+
it('the migrated tree passes --check (green end-to-end)', () => {
|
|
179
306
|
const root = makeRoot();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
307
|
+
seedLegacy(root, { hot: ['005', '006', '007'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
308
|
+
run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
309
|
+
const { code, text } = run(['--check', '--today=2026-07-09'], root);
|
|
310
|
+
assert.equal(code, 0, text);
|
|
311
|
+
assert.match(text, /OK — HOT within cap, store integrity intact, navigator fresh/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('a stray adr record NEWER than the HOT window fails exit 1 (partition) BEFORE any snapshot/delete', () => {
|
|
315
|
+
const root = makeRoot();
|
|
316
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
317
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
318
|
+
// AD-099 is newer than the HOT window (AD-005) → not a legitimate archived record → refuse.
|
|
319
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-099-stray.md'), `${fm(RECORD_CAP)}\n${adrBlock('099')}\n`);
|
|
320
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
191
321
|
assert.equal(code, 1);
|
|
192
|
-
assert.match(errText, /
|
|
193
|
-
assert.
|
|
194
|
-
assert.
|
|
322
|
+
assert.match(errText, /partition violated/);
|
|
323
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths NOT deleted on a refused migration');
|
|
324
|
+
assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on a refusal (the integrity check precedes the snapshot)');
|
|
195
325
|
});
|
|
196
|
-
});
|
|
197
326
|
|
|
198
|
-
|
|
199
|
-
it('the same input yields the same move-set (planRotation is pure)', () => {
|
|
327
|
+
it('resumes after a post-writeHot / post-partial-delete crash without wedging (idempotent) — internal-sweep major', () => {
|
|
200
328
|
const root = makeRoot();
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
329
|
+
// Reconstruct a post-crash state: HOT ALREADY trimmed to [006,007,008] (AD-005 was the exploded
|
|
330
|
+
// overflow, its source no longer in HOT); adr/ holds records 001..005; monoliths STILL present
|
|
331
|
+
// (crash before rmSync). A naive conservation would accuse AD-005 of being "invented history".
|
|
332
|
+
seedLegacy(root, { hot: ['006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
333
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
334
|
+
const recEntries = ['001', '002', '003', '004', '005'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]);
|
|
335
|
+
for (const rec of explode(recEntries, '2026-07-09')) writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
|
|
336
|
+
const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
337
|
+
assert.equal(code, 0, 'the resume completes — it never accuses its own migrated records of being stray');
|
|
338
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md', 'AD-005-decision-005.md']);
|
|
339
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008']);
|
|
340
|
+
assert.ok(!existsSync(join(root, WARM_REL)) && !existsSync(join(root, COLD_REL)), 'monoliths retired on the completed resume');
|
|
341
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the resumed tree passes --check');
|
|
206
342
|
});
|
|
207
343
|
|
|
208
|
-
it('
|
|
344
|
+
it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places — codex R4)', () => {
|
|
209
345
|
const root = makeRoot();
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
346
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
|
|
347
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
348
|
+
// AD-006 stays in the HOT window, but a store record for it already exists (a corrupt partial state).
|
|
349
|
+
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('006')]), 'x').entries, '2026-07-09')[0];
|
|
350
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
|
|
351
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
352
|
+
assert.equal(code, 1);
|
|
353
|
+
assert.match(errText, /duplicate ADR id AD-006/);
|
|
354
|
+
assert.ok(existsSync(join(root, WARM_REL)), 'monoliths NOT deleted on the refused migration');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('resumes a half-migrated tree (a byte-identical record is skipped, the rest complete)', () => {
|
|
358
|
+
const root = makeRoot();
|
|
359
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
360
|
+
run(['--migrate', '--today=2026-07-09'], root); // dry-run to observe (no writes)
|
|
361
|
+
// Simulate a crash mid-write: AD-001's record already on disk, monoliths still present.
|
|
362
|
+
const partial = explode(parseDecisionsText(readFileSync(join(root, COLD_REL), 'utf8'), COLD_REL).entries, '2026-07-09')[0];
|
|
363
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
364
|
+
writeFileSync(join(root, ADR_DIR_REL, partial.fileName), `${partial.frontmatter}\n${partial.block}\n`);
|
|
365
|
+
const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
366
|
+
assert.equal(code, 0, 'the resume completes');
|
|
367
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-002-decision-002.md', 'AD-003-decision-003.md', 'AD-004-decision-004.md']);
|
|
368
|
+
assert.ok(!existsSync(join(root, WARM_REL)), 'monoliths retired on the completed resume');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('a corrupt crash-resume record (same id, DIFFERENT body) fails loud before any write (conservation is not bypassed)', () => {
|
|
372
|
+
const root = makeRoot();
|
|
373
|
+
seedLegacy(root, { hot: ['005', '006'], warm: ['003', '004'], cold: ['001', '002'] });
|
|
374
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
375
|
+
// An AD-003 record exists on disk but with a TAMPERED body (≠ the WARM monolith's AD-003).
|
|
376
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-003-decision-003.md'), `${fm(RECORD_CAP)}\n## AD-003 — Decision 003\n\n**Date:** 2026-01-01\n\nTAMPERED body not in the monolith\n`);
|
|
377
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root);
|
|
378
|
+
assert.equal(code, 1);
|
|
379
|
+
assert.match(errText, /DIFFERENT body|corrupt or hand-edited/);
|
|
380
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths NOT deleted — the edited record is never silently overwritten');
|
|
381
|
+
assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on a refused migration (conservation precedes the snapshot)');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('off git, the snapshot falls back to a stated out-of-tree base (never the work tree)', () => {
|
|
385
|
+
const root = makeRoot();
|
|
386
|
+
const fallback = makeRoot();
|
|
387
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
388
|
+
const { code } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { spawnSync: noGit, fallbackBase: fallback });
|
|
213
389
|
assert.equal(code, 0);
|
|
214
|
-
assert.
|
|
215
|
-
assert.
|
|
216
|
-
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
|
|
390
|
+
assert.ok(existsSync(join(fallback, 'agent-workflow-adr-migration-snapshot-STAMP')), 'off-git snapshot landed in the fallback base');
|
|
391
|
+
assert.ok(!existsSync(join(root, '.git')), 'nothing written under a non-existent git dir');
|
|
217
392
|
});
|
|
218
393
|
});
|
|
219
394
|
|
|
220
|
-
describe('
|
|
221
|
-
it('
|
|
395
|
+
describe('1.3 legacy-substrate guard (Decision 6) — never half-explodes, never green on a monolith', () => {
|
|
396
|
+
it('a default rotate with a monolith present fails LOUD (run --migrate first)', () => {
|
|
222
397
|
const root = makeRoot();
|
|
223
|
-
|
|
224
|
-
const { code } = run(['--today=2026-
|
|
225
|
-
assert.equal(code,
|
|
226
|
-
|
|
227
|
-
assert.match(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
398
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
399
|
+
const { code, errText } = run(['--today=2026-07-09'], root);
|
|
400
|
+
assert.equal(code, 1);
|
|
401
|
+
assert.match(errText, /legacy monolith present/);
|
|
402
|
+
assert.match(errText, /--migrate --apply/);
|
|
403
|
+
});
|
|
404
|
+
it('--check with a monolith present fails LOUD (half-migrated), never reports green', () => {
|
|
405
|
+
const root = makeRoot();
|
|
406
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
407
|
+
const { code, errText } = run(['--check'], root);
|
|
408
|
+
assert.equal(code, 1);
|
|
409
|
+
assert.match(errText, /half-migrated/);
|
|
231
410
|
});
|
|
232
411
|
|
|
233
|
-
it('a
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
assert.
|
|
412
|
+
it('a LONE monolith (HOT and adr/ both absent) fails the legacy guard, never a clean substrate-absent skip', () => {
|
|
413
|
+
const root = makeRoot();
|
|
414
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
415
|
+
writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [adrBlock('003')]));
|
|
416
|
+
const { code, errText } = run(['--check'], root);
|
|
417
|
+
assert.equal(code, 1);
|
|
418
|
+
assert.match(errText, /legacy monolith present/);
|
|
240
419
|
});
|
|
241
420
|
});
|
|
242
421
|
|
|
243
|
-
// ── --check +
|
|
422
|
+
// ── 1.4 — --check + default rotate + item-(h) regen ────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
// A fully-migrated tree (post-migration substrate) built directly for the check/rotate tests.
|
|
425
|
+
const seedMigrated = (root, { hotIds, storeIds = [], hotCapDelta = 200, today = '2026-07-09' }) => {
|
|
426
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
427
|
+
const hotBlocks = hotIds.map((id) => adrBlock(id));
|
|
428
|
+
const probe = tierText(9999, HOT_PREAMBLE.replace(/> \*\*Archive:\*\*.*/, ''), hotBlocks);
|
|
429
|
+
const cap = lineCountOf(probe) + hotCapDelta;
|
|
430
|
+
const preamble = ['# ADRs', '', '> Newest at the bottom.'].join('\n');
|
|
431
|
+
writeFileSync(join(root, HOT_REL), tierText(cap, preamble, hotBlocks));
|
|
432
|
+
const storeEntries = storeIds.map((id) => (typeof id === 'string' ? { id } : id));
|
|
433
|
+
const records = explode(storeEntries.map((s) => parseDecisionsText(tierText(999, '# T', [adrBlock(s.id, s)]), 'x').entries[0]), today);
|
|
434
|
+
for (const rec of records) writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
|
|
435
|
+
// A fresh navigator so --check is green from the start.
|
|
436
|
+
run(['--write-navigator', `--today=${today}`], root);
|
|
437
|
+
return { cap };
|
|
438
|
+
};
|
|
244
439
|
|
|
245
|
-
describe('--check', () => {
|
|
246
|
-
it('
|
|
440
|
+
describe('1.4 --check', () => {
|
|
441
|
+
it('a green migrated tree → exit 0', () => {
|
|
247
442
|
const root = makeRoot();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
assert.equal(code, 0);
|
|
251
|
-
assert.match(text, /decisions\.md: \d+\/\d+/);
|
|
252
|
-
assert.match(text, /OK — every tier is within its cap/);
|
|
443
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001', '002'] });
|
|
444
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
|
|
253
445
|
});
|
|
254
446
|
|
|
255
|
-
it('
|
|
447
|
+
it('a duplicate id across HOT and adr/ → exit 1', () => {
|
|
256
448
|
const root = makeRoot();
|
|
257
|
-
|
|
258
|
-
const { code, errText } = run(['--check'], root);
|
|
449
|
+
seedMigrated(root, { hotIds: ['002', '005'], storeIds: ['001', '002'] }); // AD-002 in BOTH
|
|
450
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
259
451
|
assert.equal(code, 1);
|
|
260
|
-
assert.match(errText, /
|
|
261
|
-
assert.match(errText, /archive-decisions\.mjs` to rotate/);
|
|
452
|
+
assert.match(errText, /duplicate ADR id AD-002/);
|
|
262
453
|
});
|
|
263
454
|
|
|
264
|
-
it('
|
|
455
|
+
it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget — codex R4)', () => {
|
|
265
456
|
const root = makeRoot();
|
|
266
|
-
|
|
267
|
-
const
|
|
457
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
458
|
+
const noCapFm = '---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\n---\n';
|
|
459
|
+
writeFileSync(join(root, HOT_REL), `${noCapFm}\n# ADRs\n\n${adrBlock('005')}\n`);
|
|
460
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
268
461
|
assert.equal(code, 1);
|
|
269
|
-
assert.match(errText, /
|
|
462
|
+
assert.match(errText, /no maxLines cap/);
|
|
270
463
|
});
|
|
271
464
|
|
|
272
|
-
it('
|
|
465
|
+
it('NO substrate (neither decisions.md nor adr/) → exit 0 with a STATED skip', () => {
|
|
273
466
|
const root = makeRoot();
|
|
274
467
|
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
275
468
|
const { code, text } = run(['--check'], root);
|
|
276
469
|
assert.equal(code, 0);
|
|
277
|
-
assert.match(text, /SKIP —
|
|
470
|
+
assert.match(text, /SKIP — no ADR substrate/);
|
|
278
471
|
});
|
|
279
472
|
|
|
280
|
-
it('an
|
|
473
|
+
it('an adr/ record NEWER than a HOT entry fails store integrity (partition — the once-dead check is live)', () => {
|
|
281
474
|
const root = makeRoot();
|
|
282
|
-
|
|
283
|
-
const
|
|
475
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
476
|
+
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
|
|
477
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-010 > HOT AD-005
|
|
478
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
284
479
|
assert.equal(code, 1);
|
|
285
|
-
assert.match(errText, /
|
|
480
|
+
assert.match(errText, /partition violated/);
|
|
286
481
|
});
|
|
287
|
-
});
|
|
288
482
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
|
|
298
|
-
const cap = lineCountOf(probe) + capDelta;
|
|
299
|
-
writeFileSync(join(root, HOT_REL), `${fm(cap)}\n# ADRs\n\n${body}\n`);
|
|
300
|
-
return { cap, rawLines: lineCountOf(probe) };
|
|
301
|
-
};
|
|
483
|
+
it('an UNEXPECTED markdown file in adr/ fails loud (never silently hidden from the store)', () => {
|
|
484
|
+
const root = makeRoot();
|
|
485
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
486
|
+
writeFileSync(join(root, ADR_DIR_REL, 'notes.md'), '# stray notes with no frontmatter\n');
|
|
487
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
488
|
+
assert.equal(code, 1);
|
|
489
|
+
assert.match(errText, /unexpected markdown file/);
|
|
490
|
+
});
|
|
302
491
|
|
|
303
|
-
it('
|
|
492
|
+
it('TWO files for one id (a corrupt duplicate-filled store) fail loud at the SOURCE — never deduped away (council R3)', () => {
|
|
304
493
|
const root = makeRoot();
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const { code, errText } = run(['--check'], root);
|
|
308
|
-
assert.equal(code, 1
|
|
309
|
-
assert.match(errText, /
|
|
494
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
495
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-divergent-slug.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n`); // a 2nd file for AD-001
|
|
496
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
497
|
+
assert.equal(code, 1);
|
|
498
|
+
assert.match(errText, /two records for AD-001/);
|
|
310
499
|
});
|
|
311
500
|
|
|
312
|
-
it('
|
|
501
|
+
it('a rotate over a duplicate-filled store fails loud (the R2 crash-resume dedup no longer hides pre-existing dups — council R3)', () => {
|
|
313
502
|
const root = makeRoot();
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
it('a tier STILL over cap after the planned rewrite refuses BEFORE any write (a normalized rewrite is not a licence)', () => {
|
|
326
|
-
const root = makeRoot();
|
|
327
|
-
// COLD legitimately exceeds its cap (entries, not formatting): no move can fix it and a
|
|
328
|
-
// normalize-only rewrite would still be over budget — the run must refuse with files untouched.
|
|
329
|
-
seedProject(root, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001', '002'], coldCapDelta: -3 });
|
|
330
|
-
const snapshot = () => [HOT_REL, WARM_REL, COLD_REL].map((rel) => readFileSync(join(root, rel), 'utf8'));
|
|
331
|
-
const before = snapshot();
|
|
332
|
-
const { code, errText } = run(['--today=2026-01-02'], root);
|
|
503
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
504
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
505
|
+
const preamble = '# ADRs\n\n> HOT.';
|
|
506
|
+
const probe = tierText(9999, preamble, blocks);
|
|
507
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks)); // over cap → rotate proceeds
|
|
508
|
+
for (const r of explode(['001'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]), '2026-07-09')) {
|
|
509
|
+
writeFileSync(join(root, ADR_DIR_REL, r.fileName), `${r.frontmatter}\n${r.block}\n`);
|
|
510
|
+
}
|
|
511
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-second.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n`); // duplicate id
|
|
512
|
+
const { code, errText } = run(['--today=2026-07-09'], root);
|
|
333
513
|
assert.equal(code, 1);
|
|
334
|
-
assert.match(errText, /
|
|
335
|
-
assert.match(errText, /maintainer\/agent decision/);
|
|
336
|
-
assert.deepEqual(snapshot(), before, 'no file changed on the refused plan');
|
|
514
|
+
assert.match(errText, /two records for AD-001/);
|
|
337
515
|
});
|
|
338
516
|
|
|
339
|
-
it('a
|
|
517
|
+
it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory — codex R2)', () => {
|
|
340
518
|
const root = makeRoot();
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
assert.
|
|
519
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
520
|
+
mkdirSync(join(root, ADR_DIR_REL, 'nested'), { recursive: true });
|
|
521
|
+
writeFileSync(join(root, ADR_DIR_REL, 'nested', 'AD-002-x.md'), `${fm(RECORD_CAP)}\n${adrBlock('002')}\n`);
|
|
522
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
523
|
+
assert.equal(code, 1);
|
|
524
|
+
assert.match(errText, /FLAT directory/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('adr/ present but HOT absent → integrity is STILL checked (not skipped): a stale nav → exit 1', () => {
|
|
528
|
+
const root = makeRoot();
|
|
529
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001', '002'] });
|
|
530
|
+
rmSync(join(root, HOT_REL));
|
|
531
|
+
writeFileSync(join(root, NAV_REL), readFileSync(join(root, NAV_REL), 'utf8') + '\nstale junk\n');
|
|
532
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
533
|
+
assert.equal(code, 1, 'the check runs (does not skip) even with HOT absent');
|
|
534
|
+
assert.match(errText, /stale/);
|
|
346
535
|
});
|
|
347
536
|
});
|
|
348
537
|
|
|
349
|
-
describe('
|
|
350
|
-
it('
|
|
538
|
+
describe('1.4 default rotate — explode the oldest beyond cap + regenerate the index (item h)', () => {
|
|
539
|
+
it('a HOT one line over cap explodes EXACTLY the oldest entry, keeps the rest, calls the index regen once', () => {
|
|
351
540
|
const root = makeRoot();
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
const
|
|
541
|
+
// 4 HOT entries, cap = rendered - 1 → exactly one must roll out.
|
|
542
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
543
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
544
|
+
const preamble = '# ADRs\n\n> Newest at the bottom.';
|
|
545
|
+
const probe = tierText(9999, preamble, blocks);
|
|
546
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
|
|
547
|
+
run(['--write-navigator', '--today=2026-07-09'], root); // seed a fresh nav
|
|
548
|
+
const { code, regenCalls } = run(['--today=2026-07-09'], root);
|
|
357
549
|
assert.equal(code, 0);
|
|
358
|
-
assert.
|
|
359
|
-
|
|
360
|
-
assert.deepEqual(
|
|
361
|
-
assert.equal(warm.cap, 500, 'created WARM carries the default cap');
|
|
362
|
-
assert.match(warm.preamble, /AD-001 … AD-001/, 'created preamble range filled');
|
|
550
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008'], 'exactly the oldest rolled out');
|
|
551
|
+
assert.deepEqual(adrFiles(root), ['AD-005-decision-005.md'], 'AD-005 exploded to a record');
|
|
552
|
+
assert.deepEqual(regenCalls, [[root, '2026-07-09']], 'the index regen fired once with (root, today)');
|
|
363
553
|
});
|
|
364
554
|
|
|
365
|
-
it('
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
555
|
+
it('an under-cap migrated tree is a no-op (nothing written, no regen)', () => {
|
|
556
|
+
const root = makeRoot();
|
|
557
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
|
|
558
|
+
const { code, text, regenCalls } = run(['--today=2026-07-09'], root);
|
|
559
|
+
assert.equal(code, 0);
|
|
560
|
+
assert.match(text, /nothing to rotate/);
|
|
561
|
+
assert.equal(regenCalls.length, 0, 'a no-op never regenerates the index');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (codex R2)', () => {
|
|
565
|
+
const root = makeRoot();
|
|
566
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
567
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
568
|
+
const preamble = '# ADRs\n\n> Newest at the bottom.';
|
|
569
|
+
const probe = tierText(9999, preamble, blocks);
|
|
570
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks)); // AD-005 overflows
|
|
571
|
+
// Crash after writeRecords, before writeHot: AD-005 already exploded, HOT untrimmed; plus archived AD-001.
|
|
572
|
+
for (const r of explode(['001', '005'].map((id) => parseDecisionsText(tierText(999, '# T', [adrBlock(id)]), 'x').entries[0]), '2026-07-09')) {
|
|
573
|
+
writeFileSync(join(root, ADR_DIR_REL, r.fileName), `${r.frontmatter}\n${r.block}\n`);
|
|
574
|
+
}
|
|
575
|
+
const { code } = run(['--today=2026-07-09'], root);
|
|
576
|
+
assert.equal(code, 0, 'the crashed rotate resumes cleanly — no fatal duplicate on the already-written record');
|
|
577
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['006', '007', '008']);
|
|
578
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md', 'AD-005-decision-005.md']);
|
|
579
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (codex R2)', () => {
|
|
583
|
+
const root = makeRoot();
|
|
584
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] }); // under cap
|
|
585
|
+
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
|
|
586
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-010 > HOT AD-005
|
|
587
|
+
const { code, errText } = run(['--today=2026-07-09'], root);
|
|
588
|
+
assert.equal(code, 1);
|
|
589
|
+
assert.match(errText, /partition violated/);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('degrades LOUDLY when the index regen fails — the rotation itself still succeeds (exit 0)', () => {
|
|
593
|
+
const root = makeRoot();
|
|
594
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
595
|
+
const preamble = '# ADRs\n\n> Newest at the bottom.';
|
|
596
|
+
const probe = tierText(9999, preamble, blocks);
|
|
597
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
598
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
|
|
599
|
+
run(['--write-navigator', '--today=2026-07-09'], root);
|
|
600
|
+
const { code, errText } = run(['--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'the index generator is not beside this script' }) });
|
|
601
|
+
assert.equal(code, 0, 'the rotation succeeded — regen is best-effort');
|
|
602
|
+
assert.match(errText, /NOT regenerated/);
|
|
369
603
|
});
|
|
370
604
|
});
|
|
371
605
|
|
|
372
|
-
// ──
|
|
373
|
-
describe('(h) index regeneration after rotation', () => {
|
|
374
|
-
const spyRun = (argv, root, regen) => {
|
|
375
|
-
const out = [];
|
|
376
|
-
const err = [];
|
|
377
|
-
const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: regen });
|
|
378
|
-
return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
|
|
379
|
-
};
|
|
606
|
+
// ── 1.5 — navigator generator + --write-navigator ──────────────────────────────────────
|
|
380
607
|
|
|
381
|
-
|
|
608
|
+
describe('1.5 navigator — governing heads (computed), superseded drop out but stay reachable', () => {
|
|
609
|
+
it('a superseded ADR is absent from the governing list but present by filename + in the recent window', () => {
|
|
382
610
|
const root = makeRoot();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
assert.
|
|
611
|
+
// AD-002 is superseded by AD-005 (self-declared); it must drop out of governing.
|
|
612
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: [{ id: '001' }, { id: '002', status: 'Superseded by [[AD-005]]' }] });
|
|
613
|
+
const nav = readFileSync(join(root, NAV_REL), 'utf8');
|
|
614
|
+
const governingSection = nav.split('## Recent')[0];
|
|
615
|
+
assert.doesNotMatch(governingSection, /AD-002/, 'the superseded ADR is NOT a governing head');
|
|
616
|
+
assert.match(governingSection, /AD-001/, 'a governing ADR is listed');
|
|
617
|
+
assert.ok(adrFiles(root).some((f) => f.startsWith('AD-002-')), 'the superseded ADR is still reachable by filename');
|
|
618
|
+
assert.match(nav, /AD-002.*superseded/s, 'the recent window still surfaces it, marked superseded');
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('governance is computed across the corpus via inference (no predecessor mutation needed)', () => {
|
|
622
|
+
const corpus = [
|
|
623
|
+
{ id: '001', idNum: 1, title: 'A', status: 'accepted', supersedes: [], supersededBy: [], fileName: 'AD-001-a.md' },
|
|
624
|
+
{ id: '002', idNum: 2, title: 'B', status: 'accepted', supersedes: ['001'], supersededBy: [], fileName: 'AD-002-b.md' },
|
|
625
|
+
];
|
|
626
|
+
const superseded = computeSupersededSet(corpus);
|
|
627
|
+
assert.ok(superseded.has('001'), 'AD-001 is inferred superseded because AD-002 declares Supersedes [[AD-001]]');
|
|
628
|
+
assert.ok(!superseded.has('002'));
|
|
629
|
+
const nav = buildNavigator(corpus, '2026-07-09');
|
|
630
|
+
assert.match(nav, /Governing \(1\)/);
|
|
631
|
+
assert.match(nav, /AD-001 … AD-002/, 'the navigator range is numeric min…max');
|
|
388
632
|
});
|
|
389
633
|
|
|
390
|
-
it('
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
spyRun(['--dry-run'], r2, spy);
|
|
401
|
-
// a pre-write refusal (COLD exhausted)
|
|
402
|
-
const r3 = makeRoot(); seedProject(r3, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1, warmCapDelta: 3, coldCapDelta: 3 });
|
|
403
|
-
spyRun(['--today=2026-01-02'], r3, spy);
|
|
404
|
-
assert.equal(called, 0, 'the hook fires only AFTER a real write — never on no-op/check/dry-run/refusal');
|
|
634
|
+
it('a Proposed head is NOT governing; a non-accepted Supersedes does NOT retire an accepted predecessor', () => {
|
|
635
|
+
const corpus = [
|
|
636
|
+
{ id: '001', idNum: 1, title: 'A', status: 'accepted', supersedes: [], supersededBy: [], fileName: 'AD-001-a.md' },
|
|
637
|
+
{ id: '002', idNum: 2, title: 'B', status: 'proposed', supersedes: ['001'], supersededBy: [], fileName: 'AD-002-b.md' },
|
|
638
|
+
];
|
|
639
|
+
const superseded = computeSupersededSet(corpus);
|
|
640
|
+
assert.ok(!superseded.has('001'), 'a Proposed ADR does not effectively supersede its accepted predecessor');
|
|
641
|
+
const gov = buildNavigator(corpus, '2026-07-09').split('## Recent')[0];
|
|
642
|
+
assert.match(gov, /AD-001/, 'the accepted predecessor stays governing');
|
|
643
|
+
assert.doesNotMatch(gov, /\| AD-002 \|/, 'the Proposed ADR is NOT a governing head (accepted & not-superseded only)');
|
|
405
644
|
});
|
|
406
645
|
|
|
407
|
-
it('a
|
|
646
|
+
it('authoring a new HOT ADR then --write-navigator keeps --check green; a stale nav with NO write → exit 1, then --write-navigator fixes it', () => {
|
|
408
647
|
const root = makeRoot();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
writeFileSync(join(root,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
assert.
|
|
417
|
-
|
|
418
|
-
|
|
648
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
|
|
649
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'starts green');
|
|
650
|
+
|
|
651
|
+
// Corrupt the navigator without regenerating → --check must flag it stale.
|
|
652
|
+
writeFileSync(join(root, NAV_REL), readFileSync(join(root, NAV_REL), 'utf8') + '\n<!-- drift -->\n');
|
|
653
|
+
const stale = run(['--check', '--today=2026-07-09'], root);
|
|
654
|
+
assert.equal(stale.code, 1);
|
|
655
|
+
assert.match(stale.errText, /navigator|stale|log\.md/i);
|
|
656
|
+
|
|
657
|
+
// --write-navigator is the deterministic fix (never a fixless false-block).
|
|
658
|
+
assert.equal(run(['--write-navigator', '--today=2026-07-09'], root).code, 0);
|
|
659
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, '--write-navigator restored freshness');
|
|
419
660
|
});
|
|
420
661
|
|
|
421
|
-
it('
|
|
662
|
+
it('--write-navigator refuses a duplicate-id store (never emits a corrupt / duplicate-row navigator)', () => {
|
|
422
663
|
const root = makeRoot();
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
664
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
665
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs\n\n> HOT.', [adrBlock('002'), adrBlock('005')]));
|
|
666
|
+
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('002')]), 'x').entries, '2026-07-09')[0];
|
|
667
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`); // AD-002 in adr/ AND HOT
|
|
668
|
+
const { code, errText } = run(['--write-navigator', '--today=2026-07-09'], root);
|
|
669
|
+
assert.equal(code, 1);
|
|
670
|
+
assert.match(errText, /duplicate ADR id AD-002/);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('--check does NOT false-block on a mere day-rollover (uses the on-disk nav lastUpdated)', () => {
|
|
674
|
+
const root = makeRoot();
|
|
675
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'], today: '2026-07-01' });
|
|
676
|
+
// "Today" is much later, but the corpus is unchanged → the nav must stay fresh.
|
|
677
|
+
assert.equal(run(['--check', '--today=2026-07-31'], root).code, 0);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ── item (h) degrade branches on the REAL default regenerator (no injection) ───────────
|
|
682
|
+
|
|
683
|
+
describe('defaultRegenerateIndex — loud-degrade branches', () => {
|
|
684
|
+
it('an absent index generator sibling → ok:false with a recovery instruct', () => {
|
|
685
|
+
const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { sibling: '/nonexistent/check-docs-size.mjs' });
|
|
686
|
+
assert.equal(r.ok, false);
|
|
687
|
+
assert.match(r.detail, /not beside this script/);
|
|
688
|
+
});
|
|
689
|
+
it('a subprocess that exits nonzero → ok:false with the recovery instruct', () => {
|
|
690
|
+
const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { existsSync: () => true, spawnSync: () => ({ status: 1 }) });
|
|
691
|
+
assert.equal(r.ok, false);
|
|
692
|
+
assert.match(r.detail, /index regeneration failed/);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// ── coverage: defensive fail-loud branches + success/degrade paths (M3a) ───────────────
|
|
697
|
+
|
|
698
|
+
describe('defensive branches + success/degrade paths', () => {
|
|
699
|
+
it('a non-markdown stray in adr/ is IGNORED by the store (never an error)', () => {
|
|
700
|
+
const root = makeRoot();
|
|
701
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
702
|
+
writeFileSync(join(root, ADR_DIR_REL, 'notes.txt'), 'not markdown — ignored\n');
|
|
703
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('a record file holding TWO ADR blocks fails loud', () => {
|
|
707
|
+
const root = makeRoot();
|
|
708
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: [] });
|
|
709
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-two.md'), `${fm(RECORD_CAP)}\n${adrBlock('001')}\n\n${adrBlock('002')}\n`);
|
|
710
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
711
|
+
assert.equal(code, 1);
|
|
712
|
+
assert.match(errText, /exactly one ADR block/);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('a record whose filename id mismatches its heading id fails loud', () => {
|
|
716
|
+
const root = makeRoot();
|
|
717
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: [] });
|
|
718
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-mismatch.md'), `${fm(RECORD_CAP)}\n${adrBlock('002')}\n`);
|
|
719
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
720
|
+
assert.equal(code, 1);
|
|
721
|
+
assert.match(errText, /does not match the heading id/);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('a migration with NO writable snapshot base fails loud, monoliths untouched', () => {
|
|
725
|
+
const root = makeRoot();
|
|
726
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
727
|
+
const fileAsBase = join(makeRoot(), 'i-am-a-file');
|
|
728
|
+
writeFileSync(fileAsBase, 'x'); // mkdir UNDER a file → ENOTDIR → both bases fail
|
|
729
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { spawnSync: noGit, fallbackBase: fileAsBase });
|
|
730
|
+
assert.equal(code, 1);
|
|
731
|
+
assert.match(errText, /no writable snapshot location/);
|
|
732
|
+
assert.ok(existsSync(join(root, WARM_REL)), 'monoliths untouched when the snapshot cannot be written');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('defaultRegenerateIndex: a successful subprocess (status 0) → ok:true with the trimmed stdout', () => {
|
|
736
|
+
const r = defaultRegenerateIndex('/tmp/anyroot', '2026-07-09', { existsSync: () => true, spawnSync: () => ({ status: 0, stdout: 'Wrote docs/ai/index.md\n' }) });
|
|
737
|
+
assert.equal(r.ok, true);
|
|
738
|
+
assert.match(r.detail, /Wrote docs\/ai\/index\.md/);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('migrate on a tree with NO monolith and NO store → a stated "nothing to migrate"', () => {
|
|
742
|
+
const root = makeRoot();
|
|
743
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
744
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
|
|
745
|
+
const { code, text } = run(['--migrate', '--today=2026-07-09'], root);
|
|
746
|
+
assert.equal(code, 0);
|
|
747
|
+
assert.match(text, /nothing to migrate/);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('migrate --apply with a FAILING index regen degrades loudly (migration still exit 0)', () => {
|
|
751
|
+
const root = makeRoot();
|
|
752
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
753
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'boom' }) });
|
|
754
|
+
assert.equal(code, 0);
|
|
426
755
|
assert.match(errText, /NOT regenerated/);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const out = [];
|
|
434
|
-
const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => out.push(l) });
|
|
435
|
-
assert.equal(code, 0, out.join('\n'));
|
|
436
|
-
assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was regenerated by the sibling check-docs-size.mjs');
|
|
437
|
-
const idx = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
438
|
-
assert.match(idx, /decisions\.md/, 'the regenerated index lists the rotated ADR file');
|
|
439
|
-
assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'the success log line fires');
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
// The index-write outcome must be ISOLATED from the docs-cap-CHECK outcome: check-docs-size
|
|
443
|
-
// --write-index exits 1 on ANY over-cap co-located doc even after writing the index correctly, so
|
|
444
|
-
// a benign over-cap sibling must NEVER read as an index-regeneration failure (cry-wolf on the very
|
|
445
|
-
// loud-degrade channel item (h) is built around). The regen spawns --write-index --report.
|
|
446
|
-
it('the REAL regenerator succeeds even when an UNRELATED co-located doc is over its cap (no cry-wolf)', () => {
|
|
447
|
-
const root = makeRoot();
|
|
448
|
-
seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
|
|
449
|
-
// An unrelated doc over its OWN maxLines cap: --write-index writes the index fine, but without
|
|
450
|
-
// --report it would exit 1 on this file → a false "NOT regenerated" instruct.
|
|
451
|
-
writeFileSync(join(root, 'docs', 'ai', 'handover.md'), `---\ntype: state\nlastUpdated: 2026-01-02\nscope: session\nstaleAfter: 7d\nowner: none\nmaxLines: 3\n---\n\n# H\nl1\nl2\nl3\nl4\nl5\n`);
|
|
452
|
-
const out = [];
|
|
453
|
-
const err = [];
|
|
454
|
-
const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('--write-navigator on an empty tree → a stated skip', () => {
|
|
759
|
+
const root = makeRoot();
|
|
760
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
761
|
+
const { code, text } = run(['--write-navigator'], root);
|
|
455
762
|
assert.equal(code, 0);
|
|
456
|
-
assert.match(
|
|
457
|
-
assert.ok(!err.join('\n').includes('NOT regenerated'), 'no false cry-wolf on the loud-degrade channel');
|
|
458
|
-
assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was actually written');
|
|
763
|
+
assert.match(text, /SKIP — no ADR substrate/);
|
|
459
764
|
});
|
|
460
765
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
766
|
+
it('--write-navigator with a FAILING index regen degrades loudly (exit 0)', () => {
|
|
767
|
+
const root = makeRoot();
|
|
768
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
769
|
+
const { code, errText } = run(['--write-navigator', '--today=2026-07-09'], root, { regen: () => ({ ok: false, detail: 'boom' }) });
|
|
770
|
+
assert.equal(code, 0);
|
|
771
|
+
assert.match(errText, /NOT regenerated/);
|
|
772
|
+
});
|
|
468
773
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
774
|
+
it('a SINGLE HOT entry over cap fails loud (cannot be reduced below one)', () => {
|
|
775
|
+
const root = makeRoot();
|
|
776
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
777
|
+
const block = adrBlock('005', { body: 12 });
|
|
778
|
+
const probe = tierText(9999, '# ADRs', [block]);
|
|
779
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', [block]));
|
|
780
|
+
const { code, errText } = run(['--today=2026-07-09'], root);
|
|
781
|
+
assert.equal(code, 1);
|
|
782
|
+
assert.match(errText, /cannot be reduced/);
|
|
783
|
+
});
|
|
475
784
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
785
|
+
it('rotate refuses a DIVERGENT-body crash-resume record', () => {
|
|
786
|
+
const root = makeRoot();
|
|
787
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
788
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
789
|
+
const probe = tierText(9999, '# ADRs', blocks);
|
|
790
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks)); // AD-005 overflows
|
|
791
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-005-decision-005.md'), `${fm(RECORD_CAP)}\n## AD-005 — Decision 005\n\n**Date:** 2026-01-01\n\nDIVERGENT body\n`);
|
|
792
|
+
const { code, errText } = run(['--today=2026-07-09'], root);
|
|
793
|
+
assert.equal(code, 1);
|
|
794
|
+
assert.match(errText, /diverges from the freshly-exploded block/);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('rotate --dry-run prints the move-set and writes nothing', () => {
|
|
798
|
+
const root = makeRoot();
|
|
799
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
800
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
801
|
+
const probe = tierText(9999, '# ADRs', blocks);
|
|
802
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
|
|
803
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
804
|
+
const { code, text } = run(['--dry-run', '--today=2026-07-09'], root);
|
|
805
|
+
assert.equal(code, 0);
|
|
806
|
+
assert.match(text, /DRY-RUN/);
|
|
807
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
describe('usage', () => {
|
|
812
|
+
it('an unknown argument is a loud exit 2', () => {
|
|
813
|
+
const { code, errText } = run(['--frobnicate'], makeRoot());
|
|
814
|
+
assert.equal(code, 2);
|
|
815
|
+
assert.match(errText, /Unknown argument/);
|
|
816
|
+
});
|
|
817
|
+
it('--help prints usage and exits 0', () => {
|
|
818
|
+
const { code, text } = run(['--help'], makeRoot());
|
|
819
|
+
assert.equal(code, 0);
|
|
820
|
+
assert.match(text, /Usage: archive-decisions/);
|
|
481
821
|
});
|
|
482
822
|
});
|