@sabaiway/agent-workflow-memory 4.2.0 → 4.4.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 +62 -0
- package/SKILL.md +22 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size-cli.test.mjs +5 -4
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/templates/gates.json +1 -1
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
// --check-index verify docs/ai/index.md is in sync with source frontmatter;
|
|
13
13
|
// exit 1 (and print how to fix) if stale. Catches the silent
|
|
14
14
|
// drift `--write-index` is supposed to prevent.
|
|
15
|
+
// --ensure-index the idempotent finalizer every deploy/upgrade path runs after its last
|
|
16
|
+
// docs/ai mutation: probe, write only when the navigator is missing or stale,
|
|
17
|
+
// print ONE outcome line (`ensure-index: regenerated|already-current` on
|
|
18
|
+
// stdout; `ensure-index: write-refused|probe-failed — <path>: …` on stderr).
|
|
19
|
+
// Exit 0 on either written state, 2 on a named refusal — never a stack trace.
|
|
15
20
|
//
|
|
16
21
|
// CLI overrides:
|
|
17
22
|
// --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
|
|
@@ -19,10 +24,11 @@
|
|
|
19
24
|
// hook passes it so a rotation regenerates the right project's index
|
|
20
25
|
// --quiet print only failures (and final summary)
|
|
21
26
|
|
|
22
|
-
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
23
|
-
import { existsSync } from 'node:fs';
|
|
24
|
-
import { dirname, resolve, relative, join, basename } from 'node:path';
|
|
27
|
+
import { readFile, writeFile, readdir, stat, rename, rm } from 'node:fs/promises';
|
|
28
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
29
|
+
import { dirname, resolve, relative, join, basename, sep } from 'node:path';
|
|
25
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
26
32
|
|
|
27
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
34
|
const __dirname = dirname(__filename);
|
|
@@ -34,10 +40,17 @@ const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
|
|
|
34
40
|
// (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
|
|
35
41
|
// override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
|
|
36
42
|
// an arbitrary root's index without ever touching the real repo tree.
|
|
37
|
-
const pathsFor = (root) =>
|
|
43
|
+
const pathsFor = (root) => {
|
|
44
|
+
const base = resolve(root);
|
|
45
|
+
return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
|
|
46
|
+
};
|
|
38
47
|
|
|
39
48
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
40
49
|
|
|
50
|
+
// The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
|
|
51
|
+
// verbatim and the kit's ensure op reads it, so it is a contract, not a message.
|
|
52
|
+
const ENSURE_INDEX_PREFIX = 'ensure-index:';
|
|
53
|
+
|
|
41
54
|
// Project-name + footer links for the index are auto-discovered (no hardcoding):
|
|
42
55
|
// project name ← package.json "name" (fallback: repo dir basename)
|
|
43
56
|
// hierarchical ← every AGENTS.md / CLAUDE.md below the repo root
|
|
@@ -45,18 +58,30 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
|
45
58
|
const DEFAULT_PROJECT_NAME = 'this project';
|
|
46
59
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
// `strict` is the finalizer's lens on the SAME walk: for a report, an unreadable subtree is fairly
|
|
62
|
+
// skipped, but a run that WRITES the navigator may not silently treat "could not read" as "nothing
|
|
63
|
+
// there" — it would publish an index missing whatever it could not see and call that success. Only
|
|
64
|
+
// a genuine ENOENT stays an absence; every other fs error propagates.
|
|
65
|
+
// Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
|
|
66
|
+
// not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
|
|
67
|
+
const rethrowUnlessAbsent = (err, strict) => {
|
|
68
|
+
if (strict && err?.code !== 'ENOENT') throw err;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const walkForName = async (dir, name, acc = [], depth = 0, strict = false, deps = {}) => {
|
|
49
72
|
if (depth > 6) return acc;
|
|
73
|
+
const readDir = deps.readdir ?? readdir;
|
|
50
74
|
let entries;
|
|
51
75
|
try {
|
|
52
|
-
entries = await
|
|
53
|
-
} catch {
|
|
76
|
+
entries = await readDir(dir, { withFileTypes: true });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
rethrowUnlessAbsent(err, strict);
|
|
54
79
|
return acc;
|
|
55
80
|
}
|
|
56
81
|
for (const entry of entries) {
|
|
57
82
|
if (entry.isDirectory()) {
|
|
58
83
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
59
|
-
await walkForName(join(dir, entry.name), name, acc, depth + 1);
|
|
84
|
+
await walkForName(join(dir, entry.name), name, acc, depth + 1, strict, deps);
|
|
60
85
|
} else if (entry.isFile() && entry.name === name) {
|
|
61
86
|
acc.push(join(dir, entry.name));
|
|
62
87
|
}
|
|
@@ -64,16 +89,29 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
|
|
|
64
89
|
return acc;
|
|
65
90
|
};
|
|
66
91
|
|
|
67
|
-
export const discoverMeta = async (root = ROOT) => {
|
|
92
|
+
export const discoverMeta = async (root = ROOT, { strict = false, deps = {} } = {}) => {
|
|
93
|
+
const read = deps.readFile ?? readFile;
|
|
94
|
+
const readDir = deps.readdir ?? readdir;
|
|
68
95
|
let projectName = basename(root);
|
|
96
|
+
// The READ and the PARSE are separate on purpose: an unreadable package.json is a tree this run
|
|
97
|
+
// could not see (strict propagates it), while a MALFORMED one is authored content — the basename
|
|
98
|
+
// fallback, under strict too.
|
|
99
|
+
let manifest = null;
|
|
69
100
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/* no package.json — keep dir basename */
|
|
101
|
+
manifest = await read(resolve(root, 'package.json'), 'utf8');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
rethrowUnlessAbsent(err, strict);
|
|
74
104
|
}
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
if (manifest !== null) {
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(manifest);
|
|
108
|
+
if (pkg.name) projectName = pkg.name;
|
|
109
|
+
} catch {
|
|
110
|
+
/* malformed package.json — keep the dir basename */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const agentsFiles = await walkForName(root, 'AGENTS.md', [], 0, strict, deps);
|
|
114
|
+
const claudeFiles = await walkForName(root, 'CLAUDE.md', [], 0, strict, deps);
|
|
77
115
|
const rootAgents = resolve(root, 'AGENTS.md');
|
|
78
116
|
const rootClaude = resolve(root, 'CLAUDE.md');
|
|
79
117
|
// A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
|
|
@@ -91,26 +129,28 @@ export const discoverMeta = async (root = ROOT) => {
|
|
|
91
129
|
.map((rel) => `[\`${rel}\`](../../${rel})`);
|
|
92
130
|
let onDemandLinks = [];
|
|
93
131
|
try {
|
|
94
|
-
const skillDirs = await
|
|
132
|
+
const skillDirs = await readDir(resolve(root, '.agents/skills'), { withFileTypes: true });
|
|
95
133
|
onDemandLinks = skillDirs
|
|
96
134
|
.filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
|
|
97
135
|
.map((dirent) => dirent.name)
|
|
98
136
|
.sort()
|
|
99
137
|
.map((name) => `[\`${name}\`](../../.agents/skills/${name}/SKILL.md)`);
|
|
100
|
-
} catch {
|
|
101
|
-
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// No .agents/skills — omit the section (under strict, only a real absence may omit it).
|
|
140
|
+
rethrowUnlessAbsent(err, strict);
|
|
102
141
|
}
|
|
103
142
|
return { projectName, hierarchicalLinks, onDemandLinks };
|
|
104
143
|
};
|
|
105
144
|
|
|
106
145
|
// Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
|
|
107
146
|
const parseArgs = (argv) => {
|
|
108
|
-
const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
|
|
147
|
+
const flags = { report: false, writeIndex: false, checkIndex: false, ensureIndex: false, quiet: false };
|
|
109
148
|
const opts = { today: null, root: null };
|
|
110
149
|
for (const arg of argv) {
|
|
111
150
|
if (arg === '--report') flags.report = true;
|
|
112
151
|
else if (arg === '--write-index') flags.writeIndex = true;
|
|
113
152
|
else if (arg === '--check-index') flags.checkIndex = true;
|
|
153
|
+
else if (arg === '--ensure-index') flags.ensureIndex = true;
|
|
114
154
|
else if (arg === '--quiet') flags.quiet = true;
|
|
115
155
|
else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
|
|
116
156
|
else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
|
|
@@ -334,9 +374,74 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
|
334
374
|
return { fresh: expected === onDiskText, expected };
|
|
335
375
|
};
|
|
336
376
|
|
|
337
|
-
|
|
377
|
+
// The navigator is a GENERATED artifact, so its write must land on the deployment's own file and
|
|
378
|
+
// nowhere else: every component of <root>/docs/ai/index.md is lstat'ed no-follow (a symlinked root,
|
|
379
|
+
// `docs`, `docs/ai` or leaf REFUSES — publishing through one would clobber whatever it points at),
|
|
380
|
+
// the body goes out through a unique exclusive-create temp renamed into place with the chain
|
|
381
|
+
// re-checked immediately before the rename, and the temp never survives a failure. The kit runs the
|
|
382
|
+
// same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
|
|
383
|
+
// semantics are REIMPLEMENTED here rather than imported.
|
|
384
|
+
export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
|
|
385
|
+
const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
|
|
386
|
+
|
|
387
|
+
const lstatNoFollow = (target, lstat) => {
|
|
388
|
+
try {
|
|
389
|
+
return lstat(target);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (err && err.code === 'ENOENT') return null;
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
|
|
397
|
+
// in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
|
|
398
|
+
const assertContainedNoSymlink = (root, target, lstat) => {
|
|
399
|
+
const rel = relative(root, target);
|
|
400
|
+
if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
|
|
401
|
+
throw refuse(`${root} is a symlink — refusing to write the navigator through it`);
|
|
402
|
+
}
|
|
403
|
+
rel.split(sep).filter(Boolean).reduce((walked, part) => {
|
|
404
|
+
const current = join(walked, part);
|
|
405
|
+
if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
|
|
406
|
+
throw refuse(`${current} is a symlink — refusing to write the navigator through it`);
|
|
407
|
+
}
|
|
408
|
+
return current;
|
|
409
|
+
}, root);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PATH, deps = {} } = {}) => {
|
|
413
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
414
|
+
const write = deps.writeFile ?? writeFile;
|
|
415
|
+
const publish = deps.rename ?? rename;
|
|
416
|
+
const remove = deps.rm ?? rm;
|
|
417
|
+
const uniqueSuffix = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
338
418
|
const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
|
|
339
|
-
|
|
419
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
420
|
+
const tmp = `${indexPath}.${uniqueSuffix()}.tmp`;
|
|
421
|
+
assertContainedNoSymlink(root, tmp, lstat);
|
|
422
|
+
const discardTemp = async (err) => {
|
|
423
|
+
try {
|
|
424
|
+
await remove(tmp, { force: true });
|
|
425
|
+
} catch (cleanupErr) {
|
|
426
|
+
throw refuse(`${err.message} — and its temp file could not be removed, delete it by hand: ${tmp} (${cleanupErr.message})`);
|
|
427
|
+
}
|
|
428
|
+
throw err;
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
await write(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
// EEXIST means the name is SOMEONE ELSE's file: exclusive-create refused, this run wrote
|
|
434
|
+
// nothing, and removing it would delete a file we never made. Every other failure can leave a
|
|
435
|
+
// partial temp behind, and that one is ours to discard.
|
|
436
|
+
if (err && err.code === 'EEXIST') throw err;
|
|
437
|
+
await discardTemp(err);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
441
|
+
await publish(tmp, indexPath);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
await discardTemp(err);
|
|
444
|
+
}
|
|
340
445
|
};
|
|
341
446
|
|
|
342
447
|
// regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
|
|
@@ -344,15 +449,46 @@ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
|
|
|
344
449
|
// (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
|
|
345
450
|
// hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
|
|
346
451
|
// index path + row count. No second index implementation exists.
|
|
347
|
-
export const regenerateIndex = async (root, todayStr = null) => {
|
|
348
|
-
const
|
|
452
|
+
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
|
+
const paths = pathsFor(root);
|
|
349
454
|
const today = computeToday(todayStr);
|
|
350
|
-
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
351
|
-
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
455
|
+
const files = (await walkMarkdownFiles(paths.docsDir)).sort();
|
|
456
|
+
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
|
|
352
457
|
const rows = inspected.map(formatRow);
|
|
353
|
-
const meta = await discoverMeta(root);
|
|
354
|
-
await writeIndex(rows, today, meta, indexPath);
|
|
355
|
-
return { indexPath, files: rows.length };
|
|
458
|
+
const meta = await discoverMeta(paths.root);
|
|
459
|
+
await writeIndex(rows, today, meta, { root: paths.root, indexPath: paths.indexPath, deps });
|
|
460
|
+
return { indexPath: paths.indexPath, files: rows.length };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// The finalizer promises its caller EXACTLY ONE outcome line, so every step it owns — the walk, the
|
|
464
|
+
// metadata discovery, the freshness read and the write — runs inside one classified error path: an
|
|
465
|
+
// unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard runs BEFORE the
|
|
466
|
+
// freshness read for the same reason `already-present` needs a kind probe: a symlinked navigator
|
|
467
|
+
// whose target happens to hold current bytes would otherwise report `already-current` over a file
|
|
468
|
+
// this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
|
|
469
|
+
const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
470
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
471
|
+
const read = deps.readFile ?? readFile;
|
|
472
|
+
const line = (token) => `${ENSURE_INDEX_PREFIX} ${token} — ${relative(root, indexPath)}`;
|
|
473
|
+
// The two refusals name STAGES, not error codes: once the write has been entered, ANY failure —
|
|
474
|
+
// a containment refusal, EIO, EACCES — is a write refusal, because that is what the reader has to
|
|
475
|
+
// act on. A raw fs error reported as a failed PROBE would send them to the wrong half of the run.
|
|
476
|
+
let writing = false;
|
|
477
|
+
try {
|
|
478
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
479
|
+
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
480
|
+
const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
|
|
481
|
+
const rows = inspected.map(formatRow);
|
|
482
|
+
const meta = await discoverMeta(root, { strict: true, deps });
|
|
483
|
+
const onDisk = existsSync(indexPath) ? await read(indexPath, 'utf8') : null;
|
|
484
|
+
if (checkIndexFreshness(rows, onDisk, meta).fresh) return { code: 0, out: line('already-current') };
|
|
485
|
+
writing = true;
|
|
486
|
+
await writeIndex(rows, today, meta, { root, indexPath, deps });
|
|
487
|
+
return { code: 0, out: line('regenerated') };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
const cause = writing || err?.code === INDEX_WRITE_REFUSED ? 'write-refused' : 'probe-failed';
|
|
490
|
+
return { code: 2, err: `${ENSURE_INDEX_PREFIX} ${cause} — ${indexPath}: ${err.message}` };
|
|
491
|
+
}
|
|
356
492
|
};
|
|
357
493
|
|
|
358
494
|
// The return-code entry point (no process.argv / process.exit / console inside): argv[] →
|
|
@@ -370,7 +506,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
370
506
|
|
|
371
507
|
const { flags, opts, help, error } = parseArgs(argv);
|
|
372
508
|
if (help) {
|
|
373
|
-
log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
|
|
509
|
+
log('Usage: check-docs-size.mjs [--report|--write-index|--check-index|--ensure-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
|
|
374
510
|
return result(0);
|
|
375
511
|
}
|
|
376
512
|
if (error) {
|
|
@@ -379,6 +515,16 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
379
515
|
}
|
|
380
516
|
const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
|
|
381
517
|
const today = computeToday(opts.today);
|
|
518
|
+
|
|
519
|
+
// The finalizer owns its whole pipeline (above), so it returns BEFORE the shared walk: a tree the
|
|
520
|
+
// walk would throw on must still close with one outcome line.
|
|
521
|
+
if (flags.ensureIndex) {
|
|
522
|
+
const { code, out, err } = await runEnsureIndex({ root, docsDir, indexPath, today, deps });
|
|
523
|
+
if (out) log(out);
|
|
524
|
+
if (err) logError(err);
|
|
525
|
+
return result(code);
|
|
526
|
+
}
|
|
527
|
+
|
|
382
528
|
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
383
529
|
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
384
530
|
const rows = inspected.map(formatRow);
|
|
@@ -386,7 +532,12 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
386
532
|
const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
|
|
387
533
|
|
|
388
534
|
if (flags.writeIndex) {
|
|
389
|
-
|
|
535
|
+
try {
|
|
536
|
+
await writeIndex(rows, today, meta, { root, indexPath, deps });
|
|
537
|
+
} catch (err) {
|
|
538
|
+
logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
|
|
539
|
+
return result(2);
|
|
540
|
+
}
|
|
390
541
|
log(`Wrote ${relative(root, indexPath)}`);
|
|
391
542
|
const after = await stat(indexPath);
|
|
392
543
|
if (after.size === 0) {
|
|
@@ -10,10 +10,13 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstat
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
-
import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
|
|
13
|
+
import { CHECKER_CLAIM, UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, checkerClaimTool, classifyCheckerClaim, main } from './migrate-gates.mjs';
|
|
14
14
|
|
|
15
|
+
// Both core checks exist as real files — canonicity is a realpath anchor, so a check whose file is
|
|
16
|
+
// absent resolves to nothing and is no claim at all (the fail-closed answer run-gates gives too).
|
|
15
17
|
const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
|
|
16
18
|
writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
|
|
19
|
+
writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
|
|
17
20
|
|
|
18
21
|
const mkProject = (gates) => {
|
|
19
22
|
const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
|
|
@@ -34,6 +37,8 @@ const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOL
|
|
|
34
37
|
const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
|
|
35
38
|
const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
|
|
36
39
|
const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
|
|
40
|
+
// A suite the closed producer world cannot express, declaring itself with the optional marker.
|
|
41
|
+
const MARKED_SUITE = { id: 'suite', title: 'S', cmd: 'pnpm vitest run --coverage', lcovProducer: true };
|
|
37
42
|
|
|
38
43
|
describe('migrate-gates — refusal and no-op branches', () => {
|
|
39
44
|
it('--help prints the contract and exits 0', () => {
|
|
@@ -141,6 +146,146 @@ describe('migrate-gates — refusal and no-op branches', () => {
|
|
|
141
146
|
rmSync(root, { recursive: true, force: true });
|
|
142
147
|
});
|
|
143
148
|
|
|
149
|
+
it('a marker-carrying entry survives an apply UNCHANGED — the loader is lenient, the writer opaque', () => {
|
|
150
|
+
// The declaration this tool rewrites may carry keys it knows nothing about. The loader accepts
|
|
151
|
+
// any `{ gates: [...] }` shape and the writer re-serializes the ENTRY, not a reconstruction of
|
|
152
|
+
// it, so an upgrade over a marker-carrying deployment never silently drops the claim.
|
|
153
|
+
const root = mkProject([LEGACY, MARKED_SUITE, REVIEW_STATE]);
|
|
154
|
+
const io = quiet();
|
|
155
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0, io.err.join('\n'));
|
|
156
|
+
const raw = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
|
|
157
|
+
const written = JSON.parse(raw).gates;
|
|
158
|
+
assert.deepEqual(written.map((g) => g.id), ['suite', 'review-state', 'coverage-check'], 'the checker is ADDED over a marker-claimed producer');
|
|
159
|
+
assert.deepEqual(written[0], MARKED_SUITE, 'the marked entry round-trips key for key');
|
|
160
|
+
assert.match(raw, /"lcovProducer": true/, 'and the marker is really in the written bytes');
|
|
161
|
+
assert.doesNotMatch(io.out.join('\n'), /WARNING/, 'nothing is withheld over a declared producer');
|
|
162
|
+
rmSync(root, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('a marker on the CHECKER ITSELF never self-pairs — the declared pair stays INERT', () => {
|
|
166
|
+
// The producer question is POSITIONAL: the checker always ends up last, so it can never be its
|
|
167
|
+
// own producer. Asking it over the whole kept set would let this declaration certify itself
|
|
168
|
+
// into final-run-capability with nothing writing the lcov.
|
|
169
|
+
const root = mkProject([{ id: 'lint', title: 'L', cmd: 'eslint .' }, REVIEW_STATE, { ...CHECKER, lcovProducer: true }]);
|
|
170
|
+
const io = quiet();
|
|
171
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
172
|
+
const text = io.out.join('\n');
|
|
173
|
+
assert.match(text, /INERT/, 'the dead pair is named');
|
|
174
|
+
assert.doesNotMatch(text, /already final-run-capable/, 'and never claimed capable');
|
|
175
|
+
rmSync(root, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a MARKED unit-tests entry is a zero-diff keep — never extended, never reported customized', () => {
|
|
179
|
+
// Both arms the marker settles at once: `npm test` is a cmd this tool cannot verify (customized
|
|
180
|
+
// without the marker), and rewriting a cmd whose owner declared it the producer would change
|
|
181
|
+
// bytes the byte-exact hook approval binds.
|
|
182
|
+
const marked = { id: 'unit-tests', title: 'U', cmd: 'npm test', lcovProducer: true };
|
|
183
|
+
const root = mkProject([marked, REVIEW_STATE]);
|
|
184
|
+
const io = quiet();
|
|
185
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
186
|
+
const text = io.out.join('\n');
|
|
187
|
+
assert.match(text, /ADD coverage-check/, 'the claimed producer unlocks the checker');
|
|
188
|
+
assert.doesNotMatch(text, /EXTEND unit-tests/, 'a claimed producer cmd is never rewritten');
|
|
189
|
+
assert.doesNotMatch(text, /CUSTOMIZED/, 'nor reported as a cmd the tool cannot verify');
|
|
190
|
+
rmSync(root, { recursive: true, force: true });
|
|
191
|
+
|
|
192
|
+
// The SAME entry unmarked is the customized/withheld path — the marker is what settles it.
|
|
193
|
+
const bare = mkProject([{ id: 'unit-tests', title: 'U', cmd: 'npm test' }, REVIEW_STATE]);
|
|
194
|
+
const io2 = quiet();
|
|
195
|
+
assert.equal(main(['--cwd', bare, '--kit-tools', KIT_TOOLS], io2), 0, io2.err.join('\n'));
|
|
196
|
+
const text2 = io2.out.join('\n');
|
|
197
|
+
assert.match(text2, /CUSTOMIZED/);
|
|
198
|
+
assert.doesNotMatch(text2, /ADD coverage-check/, 'the checker stays withheld with no producer');
|
|
199
|
+
rmSync(bare, { recursive: true, force: true });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a marker over an UNRUNNABLE cmd never unlocks the checker — the lenient loader has no validator', () => {
|
|
203
|
+
// This tool accepts any `{ gates: [...] }` shape, so an entry the strict validator would refuse
|
|
204
|
+
// reaches the plan builder intact. A marker on such an entry must not make the migration ADD the
|
|
205
|
+
// canonical checker: the result would be the dead pair the withhold exists to prevent, and the
|
|
206
|
+
// written declaration would then fail run-gates outright.
|
|
207
|
+
for (const cmd of [' ', 'echo a\nrm -rf b']) {
|
|
208
|
+
const root = mkProject([{ id: 'suite', title: 'S', cmd, lcovProducer: true }, REVIEW_STATE]);
|
|
209
|
+
const io = quiet();
|
|
210
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
211
|
+
const text = io.out.join('\n');
|
|
212
|
+
assert.doesNotMatch(text, /ADD coverage-check/, `an unrunnable cmd must not unlock the checker: ${JSON.stringify(cmd)}`);
|
|
213
|
+
assert.match(text, /WARNING: the canonical coverage-check gate was NOT added/, 'and the withhold is stated');
|
|
214
|
+
rmSync(root, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('a marker on a DUPLICATE canonical checker never produces for the other — nor claims capability', () => {
|
|
219
|
+
// `--final` accepts exactly ONE canonical checker, and a checker cannot write the lcov it reads.
|
|
220
|
+
// Excluding only the LAST checker row from the producer search let a marker on the first one pair
|
|
221
|
+
// with the second, and the preview then called the result final-run-capable over a declaration
|
|
222
|
+
// --final rejects outright, with nothing writing the file.
|
|
223
|
+
const root = mkProject([{ ...CHECKER, id: 'coverage-check', lcovProducer: true }, REVIEW_STATE, { ...CHECKER, id: 'coverage-check-2' }]);
|
|
224
|
+
const io = quiet();
|
|
225
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
226
|
+
const text = io.out.join('\n');
|
|
227
|
+
assert.doesNotMatch(text, /already final-run-capable/, 'two checkers are never a final-run-capable result');
|
|
228
|
+
assert.match(text, /2 declared gates are the canonical coverage checker/, 'the duplication is NAMED');
|
|
229
|
+
assert.match(text, /INERT/, 'and the pair is still reported inert — nothing writes the lcov');
|
|
230
|
+
rmSync(root, { recursive: true, force: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('the tool-claim twin RUNS in this module — three outcomes, fail-closed on the unresolvable', () => {
|
|
234
|
+
// The text drift guard (beside the kit's own copy) proves the two owners are byte-equal; it
|
|
235
|
+
// cannot prove this copy WORKS, because the region is byte-equal inside a DIFFERENT host with
|
|
236
|
+
// different imports. Executing it here is what proves the twin resolves everything it uses.
|
|
237
|
+
const canonical = join(KIT_TOOLS, 'coverage-check.mjs');
|
|
238
|
+
const root = mkProject([]);
|
|
239
|
+
try {
|
|
240
|
+
const elsewhere = join(root, 'vendor-coverage-check.mjs');
|
|
241
|
+
writeFileSync(elsewhere, '// a vendored copy\n');
|
|
242
|
+
const tool = checkerClaimTool('coverage-check.mjs', canonical);
|
|
243
|
+
assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check`, KIT_TOOLS), CHECKER_CLAIM.CANONICAL);
|
|
244
|
+
const vendored = checkerClaimTool('vendor-coverage-check.mjs', canonical);
|
|
245
|
+
assert.equal(classifyCheckerClaim(vendored, `node "${elsewhere}" --check`, KIT_TOOLS), CHECKER_CLAIM.ELSEWHERE);
|
|
246
|
+
assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check || true`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a masked form is no claim');
|
|
247
|
+
assert.equal(classifyCheckerClaim(tool, `node "${join(KIT_TOOLS, 'nowhere', 'coverage-check.mjs')}" --check`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'unresolvable fails closed');
|
|
248
|
+
assert.equal(classifyCheckerClaim(tool, 'node $(pwd)/coverage-check.mjs --check', KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a shell-active bare token is no claim');
|
|
249
|
+
} finally {
|
|
250
|
+
rmSync(root, { recursive: true, force: true }); // every other case here cleans up; this one held its root only for a path
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('a VENDORED deployment previews at exit 0 and its --apply writes ZERO bytes', () => {
|
|
255
|
+
// The upgrade path this fixes: every preview AND every apply over a deployment that declared the
|
|
256
|
+
// checker through its own vendored copy used to exit 1 on an id collision, so such a deployment
|
|
257
|
+
// could not be upgraded at all.
|
|
258
|
+
const vendoredTools = mkdtempSync(join(tmpdir(), 'migrate-branches-vendored-'));
|
|
259
|
+
writeFileSync(join(vendoredTools, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
|
|
260
|
+
const vendored = { id: 'coverage-check', title: 'CC', cmd: `node "${join(vendoredTools, 'coverage-check.mjs')}" --check` };
|
|
261
|
+
const root = mkProject([UNIT_DONE, REVIEW_STATE, vendored]);
|
|
262
|
+
const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
|
|
263
|
+
|
|
264
|
+
const io = quiet();
|
|
265
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
266
|
+
const preview = io.out.join('\n');
|
|
267
|
+
assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/);
|
|
268
|
+
assert.doesNotMatch(preview, /ADD coverage-check/, 'nothing is added over a checker that is already declared');
|
|
269
|
+
assert.doesNotMatch(io.err.join('\n'), /id collision/, 'a vendored copy is not a squatter');
|
|
270
|
+
|
|
271
|
+
const io2 = quiet();
|
|
272
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io2), 0, io2.err.join('\n'));
|
|
273
|
+
assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the apply is a ZERO-DIFF write');
|
|
274
|
+
assert.match(io2.out.join('\n'), /NOT final-run-capable/, 'and the withheld claim survives the no-op apply');
|
|
275
|
+
rmSync(vendoredTools, { recursive: true, force: true });
|
|
276
|
+
rmSync(root, { recursive: true, force: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a vendored copy named by a RELATIVE path resolves against the PROJECT root, as the runner resolves it', () => {
|
|
280
|
+
const root = mkProject([UNIT_DONE, REVIEW_STATE, { id: 'coverage-check', title: 'CC', cmd: 'node "vendor/coverage-check.mjs" --check' }]);
|
|
281
|
+
mkdirSync(join(root, 'vendor'), { recursive: true });
|
|
282
|
+
writeFileSync(join(root, 'vendor', 'coverage-check.mjs'), '// a vendored copy inside the project\n');
|
|
283
|
+
const io = quiet();
|
|
284
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
285
|
+
assert.match(io.out.join('\n'), /VERIFY \(preserved exactly as declared\): coverage-check/, 'a relative token is resolved, not dismissed');
|
|
286
|
+
rmSync(root, { recursive: true, force: true });
|
|
287
|
+
});
|
|
288
|
+
|
|
144
289
|
it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
|
|
145
290
|
const root = mkProject([LEGACY, UNIT]);
|
|
146
291
|
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
|