@sabaiway/agent-workflow-kit 1.39.0 → 1.41.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 +72 -0
- package/README.md +4 -3
- package/SKILL.md +7 -3
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/doc-parity.md +18 -0
- package/references/modes/fold-completeness.md +8 -4
- package/references/modes/grounding.md +4 -3
- package/references/modes/review-ledger.md +1 -1
- package/references/modes/review-state.md +2 -1
- package/references/modes/upgrade.md +3 -1
- package/references/scripts/archive-decisions.mjs +39 -2
- package/references/scripts/archive-decisions.test.mjs +118 -1
- package/references/scripts/check-docs-size.mjs +50 -24
- package/references/scripts/check-docs-size.test.mjs +73 -1
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/verification-profile.json +10 -0
- package/tools/commands.mjs +7 -0
- package/tools/doc-parity.mjs +139 -0
- package/tools/fold-completeness-run.mjs +485 -72
- package/tools/fold-completeness.mjs +108 -15
- package/tools/grounding.mjs +92 -5
- package/tools/lcov.mjs +127 -0
- package/tools/review-ledger-core.mjs +354 -0
- package/tools/review-ledger-write.mjs +63 -4
- package/tools/review-ledger.mjs +30 -327
- package/tools/review-state.mjs +164 -15
- package/tools/run-gates.mjs +43 -4
- package/tools/sarif.mjs +52 -0
- package/tools/verification-profile.mjs +219 -0
|
@@ -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(
|
|
67
|
+
export const discoverMeta = async (root = ROOT) => {
|
|
68
|
+
let projectName = basename(root);
|
|
61
69
|
try {
|
|
62
|
-
const pkg = JSON.parse(await readFile(resolve(
|
|
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(
|
|
68
|
-
const claudeFiles = await walkForName(
|
|
69
|
-
const rootAgents = resolve(
|
|
70
|
-
const rootClaude = resolve(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
321
|
-
const after = await stat(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/tools/commands.mjs
CHANGED
|
@@ -189,6 +189,13 @@ const CATALOG = [
|
|
|
189
189
|
kind: WRITER,
|
|
190
190
|
oneLine: 'Verify the review loop’s folded fixes are pinned by HONEST tests — every changed executable line executed, and each bound test carries an observed-red receipt (--red, minted BEFORE the fix), N/N-green probes, and content custody (waivable per-testId only by a recorded red-proof override), over a test surface whose tampered files carry recorded oracle-change overrides; SEGMENT-scoped since v3 (a committed phase’s custody obligations close with its commit); --check turns the result into a gate exit code.',
|
|
191
191
|
},
|
|
192
|
+
{
|
|
193
|
+
key: 'doc-parity',
|
|
194
|
+
invocation: invocationOf('doc-parity'),
|
|
195
|
+
group: 'Orchestrate',
|
|
196
|
+
kind: READ_ONLY,
|
|
197
|
+
oneLine: 'Check that the documented contract values (review caps, schema versions, ledger vocabulary) still match the live code constants they describe — a read-only lint that fails closed on drift; --check turns it into a gate exit code.',
|
|
198
|
+
},
|
|
192
199
|
];
|
|
193
200
|
|
|
194
201
|
// Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// doc-parity.mjs — the deterministic doc-drift lint behind `/agent-workflow-kit doc-parity`
|
|
3
|
+
// (BUGFREE-3 / AD-049, economics item (b)). A whole class of BUGFREE-2 review churn came from a
|
|
4
|
+
// mode-contract doc silently lagging a code constant (a `--check` doc still saying "300" after the
|
|
5
|
+
// diff cap moved to 400). This tool closes it mechanically: a CLOSED, exported registry of bindings
|
|
6
|
+
// each ties ONE live code constant to the exact token its contract doc must carry, and the checker
|
|
7
|
+
// asserts the current value renders into every bound `references/modes/*.md` file.
|
|
8
|
+
//
|
|
9
|
+
// Why the modes/*.md docs and not the tool HELP strings: every tool's HELP INTERPOLATES the same
|
|
10
|
+
// constant (`the ${DEFAULT_DIFF_CAP}-line diff cap`), so it can never drift from the code — there is
|
|
11
|
+
// nothing to check there. The hand-authored contract prose in `references/modes/*.md` is the surface
|
|
12
|
+
// that DOES drift, so that is exactly what this lint pins. The numeric/version tokens are IMPORTED
|
|
13
|
+
// live from the tools (never re-typed here), and the ledger vocabulary is sourced from the schema's
|
|
14
|
+
// own exported `V4_CLASSES` / `V4_OVERRIDE_SCOPES` Sets — so the registry cannot itself go stale.
|
|
15
|
+
//
|
|
16
|
+
// Edit-safe by construction (the U2-DEBT closed-world lesson): adding a binding ADDS a checked entry;
|
|
17
|
+
// it never widens a blocklist. A token that stops appearing, a file that cannot be read, or an
|
|
18
|
+
// unknown binding all FAIL CLOSED — never a silent pass.
|
|
19
|
+
//
|
|
20
|
+
// Read-only: never writes, never commits, never runs a subscription CLI, spawns nothing. Dependency-
|
|
21
|
+
// free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
import { SCHEMA_VERSION, REVIEW_CAP, V4_CLASSES, V4_OVERRIDE_SCOPES } from './review-ledger.mjs';
|
|
27
|
+
import { HARD_MAX, DEFAULT_DIFF_CAP } from './review-ledger-write.mjs';
|
|
28
|
+
import { RESULT_SCHEMA_VERSION } from './fold-completeness.mjs';
|
|
29
|
+
|
|
30
|
+
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
|
|
32
|
+
const REVIEW_LEDGER_DOC = 'references/modes/review-ledger.md';
|
|
33
|
+
const FOLD_DOC = 'references/modes/fold-completeness.md';
|
|
34
|
+
|
|
35
|
+
// A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
|
|
36
|
+
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
37
|
+
|
|
38
|
+
// ── the closed binding registry ──────────────────────────────────────────────────────
|
|
39
|
+
// Each binding: { constant, value (live), token (value rendered into the doc's exact phrasing),
|
|
40
|
+
// files[] (the contract docs that MUST carry the token) }. The token phrasings match the current
|
|
41
|
+
// prose in the named files; a value drift makes the current-value token absent → a loud failure.
|
|
42
|
+
const valueBinding = (constant, value, phrase, files) => ({ constant, value, token: phrase, files });
|
|
43
|
+
|
|
44
|
+
// The ledger vocabulary — sourced from the schema's own exported Sets plus the v4 `gate-run` kind,
|
|
45
|
+
// so the closed set can never disagree with the code. Every word must appear in the ledger contract.
|
|
46
|
+
const LEDGER_VOCAB = [...V4_CLASSES, ...V4_OVERRIDE_SCOPES, 'gate-run'];
|
|
47
|
+
|
|
48
|
+
export const BINDINGS = Object.freeze([
|
|
49
|
+
valueBinding('SCHEMA_VERSION', SCHEMA_VERSION, `schema v${SCHEMA_VERSION}`, [REVIEW_LEDGER_DOC]),
|
|
50
|
+
valueBinding('HARD_MAX', HARD_MAX, `hard-max ceiling of ${HARD_MAX}`, [REVIEW_LEDGER_DOC]),
|
|
51
|
+
valueBinding('DEFAULT_DIFF_CAP', DEFAULT_DIFF_CAP, `default ${DEFAULT_DIFF_CAP}`, [REVIEW_LEDGER_DOC]),
|
|
52
|
+
valueBinding('REVIEW_CAP', REVIEW_CAP, `cap ≤${REVIEW_CAP}`, [REVIEW_LEDGER_DOC]),
|
|
53
|
+
valueBinding('RESULT_SCHEMA_VERSION', RESULT_SCHEMA_VERSION, `schema v${RESULT_SCHEMA_VERSION}`, [FOLD_DOC]),
|
|
54
|
+
...LEDGER_VOCAB.map((word) => valueBinding(`vocab:${word}`, word, word, [REVIEW_LEDGER_DOC])),
|
|
55
|
+
].map((b) => Object.freeze(b)));
|
|
56
|
+
|
|
57
|
+
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
58
|
+
// checkBinding(binding, readText) → { constant, token, files: [{ rel, ok, reason }], ok }.
|
|
59
|
+
// readText(rel) returns the file text or THROWS (an unreadable bound file fails closed).
|
|
60
|
+
export const checkBinding = (binding, readText) => {
|
|
61
|
+
const files = binding.files.map((rel) => {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
text = readText(rel);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { rel, ok: false, reason: `unreadable (${(err && err.code) || (err && err.message) || 'read failed'})` };
|
|
67
|
+
}
|
|
68
|
+
const present = text.includes(binding.token);
|
|
69
|
+
return { rel, ok: present, reason: present ? null : `token ${JSON.stringify(binding.token)} not found` };
|
|
70
|
+
});
|
|
71
|
+
return { constant: binding.constant, token: binding.token, files, ok: files.every((f) => f.ok) };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const defaultReadText = (rel) => readFileSync(resolve(KIT_ROOT, rel), 'utf8');
|
|
75
|
+
|
|
76
|
+
// checkParity(bindings, readText) → [ per-binding result ]. Default reads the real modes/*.md files
|
|
77
|
+
// relative to the kit root.
|
|
78
|
+
export const checkParity = (bindings = BINDINGS, readText = defaultReadText) => bindings.map((b) => checkBinding(b, readText));
|
|
79
|
+
|
|
80
|
+
// ── rendering ───────────────────────────────────────────────────────────────────────
|
|
81
|
+
const formatHuman = (results) => {
|
|
82
|
+
const lines = ['doc-parity — code constants ⟷ references/modes/*.md contract (read-only, BUGFREE-3)'];
|
|
83
|
+
for (const r of results) {
|
|
84
|
+
for (const f of r.files) {
|
|
85
|
+
lines.push(` ${f.ok ? '✓' : '✗'} ${r.constant} → ${f.rel}${f.ok ? '' : ` — ${f.reason}`}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const failed = results.flatMap((r) => r.files.filter((f) => !f.ok).map((f) => `${r.constant} @ ${f.rel}`));
|
|
89
|
+
lines.push(` check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.join('; ')}`}`);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const HELP = `doc-parity — deterministic doc-drift lint for the agent-workflow family (BUGFREE-3 / AD-049).
|
|
94
|
+
|
|
95
|
+
Usage:
|
|
96
|
+
node doc-parity.mjs [--check | --json]
|
|
97
|
+
|
|
98
|
+
A CLOSED, exported registry binds each live code constant (review-ledger SCHEMA_VERSION / REVIEW_CAP,
|
|
99
|
+
review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION) and the
|
|
100
|
+
ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run) to the exact token its
|
|
101
|
+
references/modes/*.md contract must carry, and asserts the CURRENT value renders into every bound
|
|
102
|
+
file. A drifted doc, an unreadable bound file, or an absent token FAILS CLOSED.
|
|
103
|
+
|
|
104
|
+
--check exits 0/1 as a gate (declare it in docs/ai/gates.json by hand). --json prints the structured
|
|
105
|
+
result. Default prints the per-binding report.
|
|
106
|
+
|
|
107
|
+
Read-only: never writes, never commits, spawns nothing. Exit codes: 0 pass (or plain report); 1 drift
|
|
108
|
+
(under --check) or error; 2 usage.`;
|
|
109
|
+
|
|
110
|
+
const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--json']);
|
|
111
|
+
|
|
112
|
+
export const main = (argv, ctx = {}) => {
|
|
113
|
+
const readText = ctx.readText ?? defaultReadText;
|
|
114
|
+
try {
|
|
115
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
116
|
+
const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
|
|
117
|
+
if (unknown !== undefined) throw usageFail(`unknown argument: ${unknown}`);
|
|
118
|
+
const results = checkParity(BINDINGS, readText);
|
|
119
|
+
const failed = results.filter((r) => !r.ok);
|
|
120
|
+
if (argv.includes('--json')) {
|
|
121
|
+
return { code: argv.includes('--check') && failed.length > 0 ? 1 : 0, stdout: JSON.stringify({ results, ok: failed.length === 0 }, null, 2), stderr: '' };
|
|
122
|
+
}
|
|
123
|
+
if (argv.includes('--check')) {
|
|
124
|
+
const reason = failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.map((r) => r.constant).join(', ')} — update the contract doc(s) in the SAME edit as the code`;
|
|
125
|
+
return { code: failed.length === 0 ? 0 : 1, stdout: `doc-parity check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${reason}`, stderr: '' };
|
|
126
|
+
}
|
|
127
|
+
return { code: 0, stdout: formatHuman(results), stderr: '' };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `doc-parity: ${err.message}` };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
134
|
+
if (isDirectRun) {
|
|
135
|
+
const r = main(process.argv.slice(2));
|
|
136
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
137
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
138
|
+
process.exitCode = r.code;
|
|
139
|
+
}
|