@sabaiway/agent-workflow-kit 1.38.0 → 1.40.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.
@@ -10,7 +10,8 @@
10
10
  // oldest (lowest AD id, top of file) first; an entry's lines move verbatim.
11
11
  //
12
12
  // Modes:
13
- // (default) rotate, mutate files in place (only when something is over cap)
13
+ // (default) rotate, mutate files in place (only when something is over cap), then regenerate
14
+ // docs/ai/index.md so the rotation never leaves the index stale (item (h))
14
15
  // --dry-run print the planned move-set, change nothing
15
16
  // --check report per-tier lines/cap; exit 1 if any tier is over its cap
16
17
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs)
@@ -41,10 +42,40 @@
41
42
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
42
43
  import { dirname, resolve } from 'node:path';
43
44
  import { fileURLToPath, pathToFileURL } from 'node:url';
45
+ import { spawnSync } from 'node:child_process';
44
46
 
45
47
  const __dirname = dirname(fileURLToPath(import.meta.url));
46
48
  const DEFAULT_ROOT = resolve(__dirname, '..');
47
49
 
50
+ // (h) — after a rotation write, docs/ai/index.md would silently go stale (a moved ADR, or just the
51
+ // bumped lastUpdated), tripping the SEPARATE `--check-index` gate mid-release-matrix. So the
52
+ // rotation regenerates the index by REUSING the ONE generator in the sibling check-docs-size.mjs
53
+ // (spawned with --write-index --root=<root>). The subprocess bridges that script's ASYNC generator,
54
+ // so this runCli stays SYNCHRONOUS (spawnSync) — the existing sync callers/tests never ripple. It
55
+ // DEGRADES LOUDLY to an instruct (never a silent failure) when the sibling is absent or the
56
+ // regeneration fails; the --check-index gate still catches a stale index. Injectable for tests.
57
+ const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
58
+ const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
59
+ // Exported + its filesystem edges injectable (deps) so BOTH degrade branches are unit-testable.
60
+ export const defaultRegenerateIndex = (root, today, deps = {}) => {
61
+ const exists = deps.existsSync ?? existsSync;
62
+ const spawn = deps.spawnSync ?? spawnSync;
63
+ const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
64
+ if (!exists(sibling)) {
65
+ return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
66
+ }
67
+ // `--report` ISOLATES the index-WRITE outcome from the docs-cap-CHECK outcome: check-docs-size
68
+ // --write-index still WRITES the index (and still exits 2 on an empty write / rejects a genuine
69
+ // throw), but --report suppresses its exit-1 on an unrelated over-cap co-located doc — otherwise a
70
+ // benign over-cap sibling would read as an index-regeneration FAILURE (a cry-wolf on this very
71
+ // loud-degrade channel).
72
+ const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
73
+ if (r.error || r.status !== 0) {
74
+ return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
75
+ }
76
+ return { ok: true, detail: (r.stdout || '').trim() };
77
+ };
78
+
48
79
  export const HOT_REL = 'docs/ai/decisions.md';
49
80
  export const WARM_REL = 'docs/ai/history/decisions-archive.md';
50
81
  export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
@@ -295,7 +326,7 @@ const parseArgs = (argv) => {
295
326
  };
296
327
 
297
328
  export const runCli = (argv, deps = {}) => {
298
- const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
329
+ const { root = DEFAULT_ROOT, log = console.log, logError = console.error, regenerateIndex = defaultRegenerateIndex } = deps;
299
330
  try {
300
331
  const { flags, today: todayOpt } = parseArgs(argv);
301
332
  if (flags.help) {
@@ -406,6 +437,10 @@ export const runCli = (argv, deps = {}) => {
406
437
  mkdirSync(dirname(tier.path), { recursive: true });
407
438
  writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
408
439
  }
440
+ // (h) — the write loop completed (a full successful rotation OR a normalize-only rewrite): the
441
+ // docs index is now stale, so regenerate it here (never on --check / --dry-run / the
442
+ // nothing-to-rotate no-op / a pre-write refusal — those return before this point).
443
+ const regen = regenerateIndex(root, today);
409
444
  log('[archive-decisions] rotated:');
410
445
  log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
411
446
  log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
@@ -413,6 +448,8 @@ export const runCli = (argv, deps = {}) => {
413
448
  log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
414
449
  }
415
450
  log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
451
+ if (regen.ok) log(' regenerated docs/ai/index.md (the rotation kept the index fresh)');
452
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
416
453
  return 0;
417
454
  } catch (err) {
418
455
  logError(`[archive-decisions] ${err.message}`);
@@ -14,6 +14,7 @@ import {
14
14
  planRotation,
15
15
  updateRangeTokens,
16
16
  runCli,
17
+ defaultRegenerateIndex,
17
18
  } from './archive-decisions.mjs';
18
19
 
19
20
  // Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
@@ -67,10 +68,14 @@ const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCa
67
68
  };
68
69
  };
69
70
 
71
+ // The existing rotation tests are about ROTATION, not the index-regen hook (item (h)) — inject a
72
+ // no-op regenerator so they stay hermetic + fast (no real check-docs-size subprocess). The hook's
73
+ // own firing/degrade behavior is pinned by the dedicated (h) describe below; the real end-to-end
74
+ // spawn is isolated to one integration test there.
70
75
  const run = (argv, root) => {
71
76
  const out = [];
72
77
  const err = [];
73
- const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
78
+ const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: () => ({ ok: true, detail: '' }) });
74
79
  return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
75
80
  };
76
81
 
@@ -363,3 +368,115 @@ describe('created tiers (a consumer with no history files yet)', () => {
363
368
  assert.match(errText, /Unknown argument/);
364
369
  });
365
370
  });
371
+
372
+ // ── (h) — a rotation regenerates docs/ai/index.md (BUGFREE-3 / AD-049) ─────────────────
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
+ };
380
+
381
+ it('calls the regenerator once with (root, today) AFTER a successful rotation write', () => {
382
+ const root = makeRoot();
383
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
384
+ const calls = [];
385
+ const { code } = spyRun(['--today=2026-01-02'], root, (r, t) => { calls.push([r, t]); return { ok: true, detail: '' }; });
386
+ assert.equal(code, 0);
387
+ assert.deepEqual(calls, [[root, '2026-01-02']], 'exactly one regen, with the rotation root + today');
388
+ });
389
+
390
+ it('does NOT regenerate on a no-op, --check, --dry-run, or a pre-write refusal', () => {
391
+ let called = 0;
392
+ const spy = () => { called += 1; return { ok: true, detail: '' }; };
393
+ // no-op (under cap)
394
+ const r1 = makeRoot(); seedProject(r1, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
395
+ spyRun([], r1, spy);
396
+ // --check
397
+ spyRun(['--check'], r1, spy);
398
+ // --dry-run over a real rotation plan
399
+ const r2 = makeRoot(); seedProject(r2, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
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');
405
+ });
406
+
407
+ it('a NORMALIZE-ONLY rewrite (stampLastUpdated bumps lastUpdated, zero moves) still triggers regeneration', () => {
408
+ const root = makeRoot();
409
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
410
+ const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
411
+ const body = blocks.join('\n\n---\n\n'); // the template separator shape: raw > cap, normalized fits
412
+ const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
413
+ writeFileSync(join(root, HOT_REL), `${fm(lineCountOf(probe) - 2)}\n# ADRs\n\n${body}\n`);
414
+ let called = 0;
415
+ const { code, text } = spyRun(['--today=2026-01-02'], root, () => { called += 1; return { ok: true, detail: '' }; });
416
+ assert.equal(code, 0, text);
417
+ assert.match(text, /normalize-only rewrite/);
418
+ assert.equal(called, 1, 'a normalize-only rewrite still leaves the index stale → regenerate');
419
+ });
420
+
421
+ it('degrades LOUDLY to an instruct when regeneration fails — the rotation still succeeds (exit 0)', () => {
422
+ const root = makeRoot();
423
+ seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
424
+ const { code, errText } = spyRun(['--today=2026-01-02'], root, () => ({ ok: false, detail: 'the index generator is not beside this script — run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md' }));
425
+ assert.equal(code, 0, 'the rotation itself succeeded — regen is best-effort');
426
+ assert.match(errText, /NOT regenerated/);
427
+ assert.match(errText, /check-docs-size\.mjs --write-index/, 'the loud instruct names the recovery command');
428
+ });
429
+
430
+ it('the REAL default regenerator writes docs/ai/index.md end-to-end (no injection)', () => {
431
+ const root = makeRoot();
432
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
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) });
455
+ assert.equal(code, 0);
456
+ assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'a benign over-cap sibling must NOT read as a regen failure');
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');
459
+ });
460
+
461
+ describe('defaultRegenerateIndex — the loud-degrade branches', () => {
462
+ it('an absent index generator sibling → a loud instruct (ok:false)', () => {
463
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { sibling: '/nonexistent/check-docs-size.mjs' });
464
+ assert.equal(r.ok, false);
465
+ assert.match(r.detail, /not beside this script/);
466
+ assert.match(r.detail, /check-docs-size\.mjs --write-index/);
467
+ });
468
+
469
+ it('a regeneration subprocess that exits nonzero → a loud instruct (ok:false)', () => {
470
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 1 }) });
471
+ assert.equal(r.ok, false);
472
+ assert.match(r.detail, /index regeneration failed/);
473
+ assert.match(r.detail, /check-docs-size\.mjs --write-index/);
474
+ });
475
+
476
+ it('a successful subprocess (status 0) → ok:true with the trimmed stdout', () => {
477
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 0, stdout: 'Wrote docs/ai/index.md\n' }) });
478
+ assert.equal(r.ok, true);
479
+ assert.match(r.detail, /Wrote docs\/ai\/index\.md/);
480
+ });
481
+ });
482
+ });
@@ -15,6 +15,8 @@
15
15
  //
16
16
  // CLI overrides:
17
17
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
18
+ // --root=<dir> run against another project root (default this deployment) — the ADR-rotation
19
+ // hook passes it so a rotation regenerates the right project's index
18
20
  // --quiet print only failures (and final summary)
19
21
 
20
22
  import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
@@ -28,6 +30,12 @@ const ROOT = resolve(__dirname, '..');
28
30
  const DOCS_DIR = resolve(ROOT, 'docs/ai');
29
31
  const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
30
32
 
33
+ // Root-parameterized (BUGFREE-3 / AD-049, item (h)): the module ROOT constants are the CLI DEFAULT
34
+ // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
35
+ // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
36
+ // an arbitrary root's index without ever touching the real repo tree.
37
+ const pathsFor = (root) => ({ root, docsDir: resolve(root, 'docs/ai'), indexPath: resolve(root, 'docs/ai/index.md') });
38
+
31
39
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
32
40
 
33
41
  // Project-name + footer links for the index are auto-discovered (no hardcoding):
@@ -56,18 +64,18 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
56
64
  return acc;
57
65
  };
58
66
 
59
- export const discoverMeta = async () => {
60
- let projectName = basename(ROOT);
67
+ export const discoverMeta = async (root = ROOT) => {
68
+ let projectName = basename(root);
61
69
  try {
62
- const pkg = JSON.parse(await readFile(resolve(ROOT, 'package.json'), 'utf8'));
70
+ const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
63
71
  if (pkg.name) projectName = pkg.name;
64
72
  } catch {
65
73
  /* no package.json — keep dir basename */
66
74
  }
67
- const agentsFiles = await walkForName(ROOT, 'AGENTS.md');
68
- const claudeFiles = await walkForName(ROOT, 'CLAUDE.md');
69
- const rootAgents = resolve(ROOT, 'AGENTS.md');
70
- const rootClaude = resolve(ROOT, 'CLAUDE.md');
75
+ const agentsFiles = await walkForName(root, 'AGENTS.md');
76
+ const claudeFiles = await walkForName(root, 'CLAUDE.md');
77
+ const rootAgents = resolve(root, 'AGENTS.md');
78
+ const rootClaude = resolve(root, 'CLAUDE.md');
71
79
  // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
72
80
  // dir once (prefer AGENTS.md, drop its sibling CLAUDE.md alias).
73
81
  const agentsDirs = new Set(agentsFiles.map((file) => dirname(resolve(file))));
@@ -78,12 +86,12 @@ export const discoverMeta = async () => {
78
86
  ),
79
87
  ];
80
88
  const hierarchicalLinks = nestedFiles
81
- .map((file) => relative(ROOT, file))
89
+ .map((file) => relative(root, file))
82
90
  .sort()
83
91
  .map((rel) => `[\`${rel}\`](../../${rel})`);
84
92
  let onDemandLinks = [];
85
93
  try {
86
- const skillDirs = await readdir(resolve(ROOT, '.agents/skills'), { withFileTypes: true });
94
+ const skillDirs = await readdir(resolve(root, '.agents/skills'), { withFileTypes: true });
87
95
  onDemandLinks = skillDirs
88
96
  .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
89
97
  .map((dirent) => dirent.name)
@@ -97,16 +105,17 @@ export const discoverMeta = async () => {
97
105
 
98
106
  const parseArgs = (argv) => {
99
107
  const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
100
- const opts = { today: null };
108
+ const opts = { today: null, root: null };
101
109
  for (const arg of argv.slice(2)) {
102
110
  if (arg === '--report') flags.report = true;
103
111
  else if (arg === '--write-index') flags.writeIndex = true;
104
112
  else if (arg === '--check-index') flags.checkIndex = true;
105
113
  else if (arg === '--quiet') flags.quiet = true;
106
114
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
115
+ else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
107
116
  else if (arg === '--help' || arg === '-h') {
108
117
  console.log(
109
- 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--quiet]',
118
+ 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
110
119
  );
111
120
  process.exit(0);
112
121
  } else {
@@ -161,11 +170,11 @@ export const computeToday = (todayStr) =>
161
170
  ? new Date(`${todayStr}T00:00:00Z`)
162
171
  : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
163
172
 
164
- export const inspectFile = async (filePath, today) => {
173
+ export const inspectFile = async (filePath, today, root = ROOT) => {
165
174
  const text = await readFile(filePath, 'utf8');
166
175
  const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
167
176
  const fm = parseFrontmatter(text);
168
- const rel = relative(ROOT, filePath);
177
+ const rel = relative(root, filePath);
169
178
 
170
179
  if (!fm) {
171
180
  return {
@@ -301,24 +310,41 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
301
310
  return { fresh: expected === onDiskText, expected };
302
311
  };
303
312
 
304
- const writeIndex = async (rows, today, meta) => {
313
+ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
305
314
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
306
- await writeFile(INDEX_PATH, body, 'utf8');
315
+ await writeFile(indexPath, body, 'utf8');
316
+ };
317
+
318
+ // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
319
+ // the SAME walk → inspect → discoverMeta → writeIndex pipeline as `--write-index`, against `root`
320
+ // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
321
+ // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
322
+ // index path + row count. No second index implementation exists.
323
+ export const regenerateIndex = async (root, todayStr = null) => {
324
+ const { docsDir, indexPath } = pathsFor(root);
325
+ const today = computeToday(todayStr);
326
+ const files = (await walkMarkdownFiles(docsDir)).sort();
327
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
328
+ const rows = inspected.map(formatRow);
329
+ const meta = await discoverMeta(root);
330
+ await writeIndex(rows, today, meta, indexPath);
331
+ return { indexPath, files: rows.length };
307
332
  };
308
333
 
309
334
  const main = async () => {
310
335
  const { flags, opts } = parseArgs(process.argv);
336
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
311
337
  const today = computeToday(opts.today);
312
- const files = (await walkMarkdownFiles(DOCS_DIR)).sort();
313
- const inspected = await Promise.all(files.map((f) => inspectFile(f, today)));
338
+ const files = (await walkMarkdownFiles(docsDir)).sort();
339
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
314
340
  const rows = inspected.map(formatRow);
315
341
 
316
- const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta() : null;
342
+ const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
317
343
 
318
344
  if (flags.writeIndex) {
319
- await writeIndex(rows, today, meta);
320
- console.log(`Wrote ${relative(ROOT, INDEX_PATH)}`);
321
- const after = await stat(INDEX_PATH);
345
+ await writeIndex(rows, today, meta, indexPath);
346
+ console.log(`Wrote ${relative(root, indexPath)}`);
347
+ const after = await stat(indexPath);
322
348
  if (after.size === 0) {
323
349
  console.error('index.md was written empty');
324
350
  process.exit(2);
@@ -326,16 +352,16 @@ const main = async () => {
326
352
  }
327
353
 
328
354
  if (flags.checkIndex) {
329
- const onDisk = existsSync(INDEX_PATH) ? await readFile(INDEX_PATH, 'utf8') : null;
355
+ const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
330
356
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
331
357
  if (!fresh) {
332
358
  console.error(
333
- `[check-docs-size] FAIL: ${relative(ROOT, INDEX_PATH)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
359
+ `[check-docs-size] FAIL: ${relative(root, indexPath)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
334
360
  );
335
361
  process.exit(1);
336
362
  }
337
363
  console.log(
338
- `[check-docs-size] OK — ${relative(ROOT, INDEX_PATH)} is in sync with source frontmatter.`,
364
+ `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
339
365
  );
340
366
  return;
341
367
  }
@@ -1,8 +1,11 @@
1
1
  import { describe, it, beforeEach, afterEach } from 'node:test';
2
2
  import { expect } from './_expect-shim.mjs';
3
- import { mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { mkdtemp, writeFile, mkdir, readFile, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
4
6
  import { tmpdir } from 'node:os';
5
7
  import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
6
9
  import {
7
10
  parseFrontmatter,
8
11
  parseStaleAfter,
@@ -11,6 +14,7 @@ import {
11
14
  buildIndex,
12
15
  checkIndexFreshness,
13
16
  walkMarkdownFiles,
17
+ regenerateIndex,
14
18
  } from './check-docs-size.mjs';
15
19
 
16
20
  describe('parseFrontmatter', () => {
@@ -202,3 +206,71 @@ describe('checkIndexFreshness', () => {
202
206
  expect(checkIndexFreshness(rows, onDisk).fresh).toBe(true);
203
207
  });
204
208
  });
209
+
210
+ // ── (h) — root-parameterization: the ADR-rotation hook regenerates ANOTHER root's index ────
211
+ // The generator's module ROOT is the CLI default only; --root / regenerateIndex(root) target an
212
+ // arbitrary tree so the rotation hook (and hermetic tests) never touch the real repo.
213
+ describe('root parameterization (item (h))', () => {
214
+ let root;
215
+ beforeEach(async () => {
216
+ root = await mkdtemp(join(tmpdir(), 'check-docs-root-'));
217
+ await mkdir(join(root, 'docs', 'ai'), { recursive: true });
218
+ });
219
+ afterEach(async () => {
220
+ await rm(root, { recursive: true, force: true });
221
+ });
222
+
223
+ const seedDoc = (name, extra = '') =>
224
+ writeFile(join(root, 'docs', 'ai', name), `---\ntype: reference\nlastUpdated: 2026-07-08\nscope: permanent\nstaleAfter: 30d\nowner: none\nmaxLines: 100\n---\n\n# ${name}\n${extra}`);
225
+
226
+ it('inspectFile computes the file path RELATIVE to the passed root (not the module ROOT)', async () => {
227
+ await seedDoc('handover.md');
228
+ const result = await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-07-08'), root);
229
+ expect(result.path).toBe('docs/ai/handover.md');
230
+ });
231
+
232
+ it('regenerateIndex(root, today) writes THAT root\'s docs/ai/index.md from its frontmatter', async () => {
233
+ await seedDoc('a.md');
234
+ await seedDoc('b.md');
235
+ const res = await regenerateIndex(root, '2026-07-08');
236
+ expect(res.indexPath).toBe(join(root, 'docs', 'ai', 'index.md'));
237
+ expect(existsSync(res.indexPath)).toBe(true);
238
+ const index = await readFile(res.indexPath, 'utf8');
239
+ expect(index).toMatch(/lastUpdated: 2026-07-08/); // header date is the argument
240
+ expect(index).toMatch(/a\.md/);
241
+ expect(index).toMatch(/b\.md/);
242
+ expect(index).not.toMatch(/\[`index\.md`\]/); // the index never lists itself
243
+ });
244
+
245
+ it('a re-run with unchanged sources is byte-identical (deterministic, --check-index safe)', async () => {
246
+ await seedDoc('a.md');
247
+ const first = await regenerateIndex(root, '2026-07-08');
248
+ const bytesA = await readFile(first.indexPath, 'utf8');
249
+ await regenerateIndex(root, '2026-07-08');
250
+ const bytesB = await readFile(first.indexPath, 'utf8');
251
+ expect(bytesB).toBe(bytesA);
252
+ });
253
+
254
+ // The CLI entry (main) over --root — a subprocess smoke so the --help usage + the --check-index
255
+ // fresh/stale branches (root-parameterized) are exercised end-to-end.
256
+ const SCRIPT = fileURLToPath(new URL('./check-docs-size.mjs', import.meta.url));
257
+ const runCli = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
258
+
259
+ it('--help prints the usage (naming --root) and exits 0', () => {
260
+ const r = runCli(['--help']);
261
+ expect(r.status).toBe(0);
262
+ expect(r.stdout).toMatch(/--root=/);
263
+ });
264
+
265
+ it('--check-index --root: a fresh index is OK (exit 0); a drifted one is stale (exit 1)', async () => {
266
+ await seedDoc('a.md');
267
+ expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
268
+ const fresh = runCli(['--check-index', `--root=${root}`]);
269
+ expect(fresh.status).toBe(0);
270
+ expect(fresh.stdout).toMatch(/in sync/);
271
+ await seedDoc('b.md'); // a new source row drifts the on-disk index
272
+ const stale = runCli(['--check-index', `--root=${root}`]);
273
+ expect(stale.status).toBe(1);
274
+ expect(stale.stderr).toMatch(/stale/);
275
+ });
276
+ });
@@ -53,6 +53,7 @@ Before proposing changes or committing, review against:
53
53
  ### 2.2. Clean Code
54
54
  - **No magic literals** — extract string/numeric constants to named consts at module level.
55
55
  - **DRY** — no duplicated logic.
56
+ - **Minimal comments (a BASELINE this project may tighten)** — if this project sets a stricter rule (e.g. comments forbidden entirely), that stricter rule ALWAYS wins; this is only a floor. Otherwise comment only where vitally necessary (a non-obvious invariant, a fail-closed rationale, a subtle edge). Make the code self-explaining first — clear variable/function names and compact-but-unambiguous test descriptions replace most comments; never restate what the code already says.
56
57
 
57
58
  ### 2.3. Strict Compliance
58
59
  - Only `const` (no `let`); no classes — pure functions, closures, modules.
@@ -0,0 +1,10 @@
1
+ {
2
+ "_README": "Optional per-project VERIFICATION PROFILE for the fold-completeness gate (the language-independence contract). DELETE this file to reproduce the exact default behaviour (V8 line coverage + node:test TAP on stdout) — an absent profile is fully supported. Present, it GENERALIZES three inputs so a consumer on another language/runner can drive the same gate: (1) coverage.kind is \"v8\" (default) or \"lcov\" — with lcov, set coverage.lcovPath to where your suite leaves an LCOV file; (2) singleTest.argv is the shell-free command template for probing ONE test (placeholders {file} and {pattern} are required; a file-based resultFormat also requires {resultPath}), and singleTest.resultFormat is \"tap-stdout\" (default), \"tap-file\", or \"junit-xml\"; (3) findings.sarifPath (optional) points at a SARIF file for advisory-only findings (never gate-blocking). The suite COMMAND is NOT declared here — it stays your docs/ai/gates.json unit-tests gate (so the fold run and the gate share command-identity). Every DECLARED path (coverage.lcovPath, findings.sarifPath) MUST be gitignored or outside the repo (a symlink is refused): an in-tree, non-ignored file the suite writes would move the review fingerprint. Env knobs still override (AW_FOLD_SUITE_CMD / AW_FOLD_BOUND_CMD / AW_FOLD_RESULTS). Strict JSON — no comments.",
3
+ "schema": 1,
4
+ "coverage": { "kind": "v8" },
5
+ "singleTest": {
6
+ "argv": ["node", "--test", "--test-reporter", "tap", "--test-name-pattern={pattern}", "{file}"],
7
+ "resultFormat": "tap-stdout"
8
+ },
9
+ "findings": {}
10
+ }