Files
msd-core/tests/inventory-manifest-sync.test.cjs
Tom Boucher b6977d9d11 fix(#3762): enforce docs/INVENTORY.md roster rows, backfill 32 gaps (#3766)
* test(#3762): failing-first roster gate for docs/INVENTORY.md rows

Anchors the human half of the inventory-drift rule: every entry in
docs/INVENTORY-MANIFEST.json must have a hand-written row in
docs/INVENTORY.md. Expected RED on this commit -- next carries 32
unrostered surfaces, which is the defect the gate exists to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3762): enforce docs/INVENTORY.md roster rows, backfill 32 gaps

docs/INVENTORY.md calls itself the authoritative roster of every shipped
GSD surface, and CLAUDE.md's inventory-drift rule requires both a roster
row and a manifest regen. Only the manifest half was anchored, so a PR
could ship a surface, regenerate the manifest, omit the row, and stay
green -- as PR #3758 did with gsd-core/references/planner-coupling.md.

Adds the roster half to tests/inventory-manifest-sync.test.cjs, backed by
a pure matcher in tests/helpers/inventory-roster.cjs, and backfills the 32
surfaces already missing rows on next.

Fixes #3762

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3762): harden roster matcher against fenced blocks and trim exports

Skips fenced code regions when splitting level-2 sections so a documented
'## ' example inside a fence cannot truncate a family section (false red)
or contribute a phantom row (false pass); makes the heading pattern linear
rather than a backtracking lazy match; narrows the module surface to the
three names the gate consumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3762): close two false-RED gaps found in orthogonal review

Indented headings: CommonMark permits an ATX heading to carry 1-3 leading
spaces, and a ^##-anchored pattern read such a document as having no family
sections at all -- reporting all six missing, a structural red for zero real
drift. Reproduced, then fixed and pinned.

Link-wrapped cells: a row written as [`x.md`](../x.md) was not recognized,
though docs/INVENTORY.md already uses that form elsewhere. Unwrapping now
peels a whole-cell link and a whole-cell code span, and only layers that
wrap the cell entirely -- a file mentioned mid-prose is still not a row, so
the false red is not traded for a false pass.

Adds a second fast-check property over the commands source-link rule, the
one family whose matching rule differs from the other five.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3762): boundary trio for the fence-marker length limit

RULESET.TESTS.boundary-coverage — the matcher's only numeric limit is the
fence marker's {3,}. Exercises 2 (inline markup, swallows nothing), 3, and
4 characters, for both backtick and tilde delimiters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: remove stray pwned_cmdsub injection-test canary from the repo root

A zero-byte file committed by 0e6fa2e2c (#3124) while remediating the
#3118 command-substitution injection — the marker a test wrote into cwd to
prove a substitution had NOT executed, left behind when the run ended.
Nothing in the tree references it (verified by Grep across the repo),
package.json's files array excludes root-level files so it never shipped,
and lint-removed-but-needed confirms no surviving reference.

Found while auditing the repo root for #3762; fixed inline rather than
deferred, per CLAUDE.md's no-deferrals rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: backfill changeset pr number to 3766

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:24:25 -04:00

476 lines
20 KiB
JavaScript

'use strict';
/**
* Anchors BOTH halves of the inventory-drift rule (`CLAUDE.md` → Inventory Drift:
* "requires updating `docs/INVENTORY.md` AND running
* `node scripts/gen-inventory-manifest.cjs --write`"):
*
* 1. `docs/INVENTORY-MANIFEST.json` matches the filesystem — the machine half.
* Fix by running: node scripts/gen-inventory-manifest.cjs --write
* 2. `docs/INVENTORY.md` carries a roster row for every manifest entry — the human
* half (#3762). Fix by hand-writing the row.
*
* Until #3762 only half 1 existed, so a PR could ship a surface, regenerate the
* manifest, omit the roster row, and pass CI green — while `docs/INVENTORY.md:3`
* called itself "Authoritative roster of every shipped GSD surface" and
* `docs/INVENTORY.md:681` promised "a new file without a matching row here will fail
* CI". PR #3758 is the live instance: `gsd-core/references/planner-coupling.md`,
* manifest entry present, roster row absent, CI 30 success / 0 failing.
*
* The roster comparison itself lives in `tests/helpers/inventory-roster.cjs` — read
* its header for exactly which families are enforced, which two are deliberately
* exempt, and what shapes a row may legally take. The fixture rows at the bottom of
* this file drive that matcher directly, because the live assertion passes whenever
* the repo is clean and therefore proves nothing about the comparison on its own.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const {
ROSTER_SECTIONS,
findMissingRosterRows,
formatRosterFailure,
} = require('./helpers/inventory-roster.cjs');
const ROOT = path.resolve(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
const INVENTORY_PATH = path.join(ROOT, 'docs', 'INVENTORY.md');
// #2996: FAMILIES and NESTED_FAMILIES are IMPORTED, never redeclared. This file used to
// carry its own copy of the family table — the `DEFECT.GENERATIVE-FIX` divergence class:
// a family added to the generator but not here left this test silently verifying a
// subset while still reporting green. Importing makes divergence impossible rather than
// merely detectable.
const { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs');
test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const additions = [];
const removals = [];
for (const { name, dir, filter, toName } of FAMILIES) {
const flat = fs.readdirSync(dir)
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
.map(toName);
// `cli_modules` also ships one level of subdirectory modules (#3309); mirror
// buildManifest's special-case merge exactly, or this test would report every
// subdirectory file as a phantom removal.
const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : [];
const live = new Set([...flat, ...nested]);
const recorded = new Set((committed.families || {})[name] || []);
for (const entry of live) {
if (!recorded.has(entry)) additions.push(name + '/' + entry);
}
for (const entry of recorded) {
if (!live.has(entry)) removals.push(name + '/' + entry);
}
}
for (const family of NESTED_FAMILIES) {
const live = new Set(collectNested(family));
const recorded = new Set((committed.families || {})[family.name] || []);
for (const entry of live) {
if (!recorded.has(entry)) additions.push(family.name + '/' + entry);
}
for (const entry of recorded) {
if (!live.has(entry)) removals.push(family.name + '/' + entry);
}
}
const msg = [
additions.length ? 'New surfaces not in manifest (run node scripts/gen-inventory-manifest.cjs --write):\n' + additions.map((e) => ' + ' + e).join('\n') : '',
removals.length ? 'Manifest entries with no matching file:\n' + removals.map((e) => ' - ' + e).join('\n') : '',
].filter(Boolean).join('\n');
assert.ok(additions.length === 0 && removals.length === 0, msg);
});
// ─── #3762: the roster half ──────────────────────────────────────────────────
test('docs/INVENTORY.md carries a roster row for every manifest entry', () => {
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const inventory = fs.readFileSync(INVENTORY_PATH, 'utf8');
const findings = findMissingRosterRows(inventory, committed.families);
assert.ok(
findings.missingSections.length === 0 && findings.missingRows.length === 0,
formatRosterFailure(findings),
);
});
// ─── Fixture rows: the gate must be able to FAIL, and only for the right reason ──
const FIXTURE_HEAD = '# Fixture\n\n';
/** Assemble a fixture document from `{heading: bodyText}`, optionally with CRLF endings. */
function doc(sectionsBySpec, { crlf = false, indent = '' } = {}) {
let out = FIXTURE_HEAD;
for (const [heading, body] of Object.entries(sectionsBySpec)) {
out += indent + '## ' + heading + '\n\n' + body + '\n\n';
}
return crlf ? out.replace(/\n/g, '\r\n') : out;
}
/** Every in-scope section present and empty, so a row supplies only the table it cares about. */
function emptySections(overrides = {}, opts) {
const spec = {};
for (const heading of Object.values(ROSTER_SECTIONS)) spec[heading] = '(no rows)';
return doc({ ...spec, ...overrides }, opts);
}
test('row 2 — the PR #3758 shape: a manifest reference with no roster row is reported', () => {
// gsd-core/references/planner-coupling.md shipped in PR #3758 with a manifest entry,
// all 19 tests/fixtures/install-tree/*.json updated, and no row in
// §"Modular Planner Decomposition". That PR's CI was 30 success / 3 skipped / 0
// failing. This row is the reason it would not be.
const text = emptySections({
References: '| Reference | Role |\n|---|---|\n| `checkpoints.md` | Checkpoint types. |',
});
const { missingSections, missingRows } = findMissingRosterRows(text, {
references: ['checkpoints.md', 'planner-coupling.md'],
});
assert.deepStrictEqual(missingSections, []);
assert.deepStrictEqual(missingRows, ['references/planner-coupling.md']);
});
test('row 3 — adding the row clears the report', () => {
const text = emptySections({
References:
'| Reference | Role |\n|---|---|\n| `checkpoints.md` | Checkpoint types. |\n' +
'| `planner-coupling.md` | Planner coupling rules. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['checkpoints.md', 'planner-coupling.md'] }).missingRows,
[],
);
});
test('row 4 — a substring of a longer rostered cell is not a row', () => {
// The real docs/INVENTORY.md rosters `host-integration-adapters/imperative-hook-bus.cjs`.
// A substring match would report the separate, genuinely-unrostered top-level
// `hook-bus.cjs` as satisfied — a false pass on a shipped module.
const text = emptySections({
'CLI Modules':
'| Module | Responsibility |\n|---|---|\n' +
'| `host-integration-adapters/imperative-hook-bus.cjs` | Imperative hook-bus adapter. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, {
cli_modules: ['host-integration-adapters/imperative-hook-bus.cjs', 'hook-bus.cjs'],
}).missingRows,
['cli_modules/hook-bus.cjs'],
);
});
test('row 5 — a row in another family section does not satisfy this family', () => {
// `smart-entry.md` (a workflow) and `smart-entry.cjs` (a CLI module) are different
// surfaces sharing a stem; an unscoped document-wide search conflates two families
// the moment they ever do share a full name.
const text = emptySections({
'CLI Modules': '| Module | Responsibility |\n|---|---|\n| `smart-entry.md` | Wrong section. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { workflows: ['smart-entry.md'] }).missingRows,
['workflows/smart-entry.md'],
);
});
test('row 6 — a command row is matched on its Source link, not its display name', () => {
const text = emptySections({
Commands:
'| Command | Role | Source |\n|---|---|---|\n' +
'| `/gsd-workflow` | Phase pipeline router. | [commands/gsd/ns-workflow.md](../commands/gsd/ns-workflow.md) |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { commands: ['/gsd-ns-workflow'] }).missingRows,
[],
'the six namespace routers deliberately render a name that is not their file stem',
);
});
test('row 7 — a command row without a Source link is not a row', () => {
const text = emptySections({
Commands: '| Command | Role | Source |\n|---|---|---|\n| `/gsd-quick` | Quick task. | (todo) |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { commands: ['/gsd-quick'] }).missingRows,
['commands//gsd-quick'],
);
});
test('row 8 — an unbackticked agent cell is a row', () => {
// Agents is the one family whose identity column carries a bare, unbackticked name.
const text = emptySections({
Agents:
'| Agent | Role | Spawned by | Primary doc |\n|---|---|---|---|\n' +
'| gsd-planner | Creates executable phase plans. | `/gsd-plan-phase` | primary |',
});
assert.deepStrictEqual(findMissingRosterRows(text, { agents: ['gsd-planner'] }).missingRows, []);
});
test('row 9 — trailing HTML comments and extra columns are tolerated', () => {
const text = emptySections({
'CLI Modules':
'| Module | Responsibility | Extra |\n|---|---|---|\n' +
'| `installer-migrations/003-rename.cjs` | Migration. | n/a |<!-- gsd-allow-legacy-name -->',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { cli_modules: ['installer-migrations/003-rename.cjs'] }).missingRows,
[],
);
});
test('row 10 — CRLF line endings are tolerated', () => {
const spec = {};
for (const heading of Object.values(ROSTER_SECTIONS)) spec[heading] = '(no rows)';
spec.Hooks = '| Hook | Event | Purpose |\n|---|---|---|\n| `gsd-statusline.js` | `statusLine` | Statusline. |';
const text = doc(spec, { crlf: true });
assert.deepStrictEqual(
findMissingRosterRows(text, { hooks: ['gsd-statusline.js'] }).missingRows,
[],
'a \\n-only split leaves a trailing \\r on every cell and reds every Windows checkout',
);
});
test('row 10b — a fenced code block does not split a section or contribute cells', () => {
// docs/INVENTORY.md carries no fence today. The day someone documents a `## `
// example inside one, an unfenced scanner truncates the family section at that
// line and reds a document that is perfectly correct — and a pipe-delimited line
// inside the fence would count as a row it is not.
const text = emptySections({
References:
'```\n## References\n| `imposter.md` | not a row |\n```\n\n' +
'| Reference | Role |\n|---|---|\n| `real.md` | Real. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['real.md', 'imposter.md'] }).missingRows,
['references/imposter.md'],
);
});
// ─── Fence-length boundary: the matcher's only numeric limit is `{3,}` ──────────
//
// RULESET.TESTS.boundary-coverage — exercise limit-1 / limit / limit+1 on the fence
// marker, for both delimiter characters. Two backticks is a plain inline code span
// and must NOT swallow the rows beneath it; three and four must.
test('row 10f — a TWO-character run is not a fence, and swallows nothing (limit-1)', () => {
for (const mark of ['``', '~~']) {
const text = emptySections({
References: mark + '\n| `kept.md` | Role. |\n' + mark,
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['kept.md'] }).missingRows,
[],
'a two-' + mark[0] + ' run is inline markup, not a fence — treating it as one hides a real row',
);
}
});
test('row 10g — a THREE-character run opens and closes a fence (limit)', () => {
for (const mark of ['```', '~~~']) {
const text = emptySections({
References: mark + '\n| `swallowed.md` | Role. |\n' + mark + '\n\n| `kept.md` | Role. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['kept.md', 'swallowed.md'] }).missingRows,
['references/swallowed.md'],
);
}
});
test('row 10h — a FOUR-character run opens and closes a fence (limit+1)', () => {
for (const mark of ['````', '~~~~']) {
const text = emptySections({
References: mark + '\n| `swallowed.md` | Role. |\n' + mark + '\n\n| `kept.md` | Role. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['kept.md', 'swallowed.md'] }).missingRows,
['references/swallowed.md'],
'a longer run is still a fence; only `{3,}` matters, not an exact count',
);
}
});
test('row 10c — a heading indented up to 3 spaces is still a heading (CommonMark)', (t) => {
// Anchoring hard at /^##/ looks harmless and is not. CommonMark permits an ATX
// heading to carry 1-3 leading spaces, so an author who indents one writes a
// perfectly valid document that a `^##`-anchored scanner reads as having NO family
// sections at all — all six reported missing, a structural red for zero real drift.
const text = emptySections(
{ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' },
{ indent: ' ' },
);
const { missingSections, missingRows } = findMissingRosterRows(text, { references: ['only.md'] });
assert.deepStrictEqual(missingSections, [], 'a 3-space indent must not erase every family section');
assert.deepStrictEqual(missingRows, [], 'rows under an indented heading are still rows');
t.diagnostic('CommonMark 4.2: an ATX heading may be indented 0-3 spaces');
});
test('row 10d — a cell that is a link wrapping a code span is a row', () => {
// docs/INVENTORY.md already writes file references as [`docs/AGENTS.md`](AGENTS.md)
// in prose. The first contributor who writes a FAMILY ROW that way gets an
// inexplicable red on a row that plainly documents the file.
const text = emptySections({
References: '| Reference | Role |\n|---|---|\n| [`linked.md`](../references/linked.md) | Linked. |',
});
assert.deepStrictEqual(findMissingRosterRows(text, { references: ['linked.md'] }).missingRows, []);
});
test('row 10e — a file merely MENTIONED in a role cell is still not a row', () => {
// The guard on row 10d. Unwrapping presentation must peel only layers that wrap the
// cell ENTIRELY; the moment a code span mentioned mid-prose counts, the gate has
// traded a false red for a false pass, which is the strictly worse failure.
const text = emptySections({
References: '| Reference | Role |\n|---|---|\n| `real.md` | Superseded by `ghost.md` in the role prose. |',
});
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['real.md', 'ghost.md'] }).missingRows,
['references/ghost.md'],
);
});
test('row 11 — a missing family section is reported as a section failure', () => {
const text = '# Fixture\n\n## Agents\n\n| Agent |\n|---|\n| gsd-planner |\n';
const { missingSections, missingRows } = findMissingRosterRows(text, {
agents: ['gsd-planner'],
cli_modules: ['a.cjs', 'b.cjs', 'c.cjs'],
});
assert.deepStrictEqual(missingSections, ['Commands', 'Workflows', 'References', 'CLI Modules', 'Hooks']);
assert.deepStrictEqual(
missingRows,
[],
'a missing section is ONE structural failure, not one phantom row per entry inside it',
);
});
test('row 12 — an empty family contributes nothing (limit-1)', () => {
assert.deepStrictEqual(
findMissingRosterRows(emptySections(), { references: [] }),
{ missingSections: [], missingRows: [] },
);
});
test('row 13 — exactly one entry, rostered (limit)', () => {
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' });
assert.deepStrictEqual(findMissingRosterRows(text, { references: ['only.md'] }).missingRows, []);
});
test('row 14 — one more entry than the roster carries is the one reported (limit+1)', () => {
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n| `only.md` | Only. |' });
assert.deepStrictEqual(
findMissingRosterRows(text, { references: ['only.md', 'extra.md'] }).missingRows,
['references/extra.md'],
);
});
test('row 16 — nested step/mode families are deliberately out of scope', () => {
// docs/INVENTORY.md §"Workflow Sub-Files": "Adding a step or mode file requires no
// hand-written row here". If that decision is ever reversed, this row is what has to
// change first — deliberately and in the open, rather than by widening
// ROSTER_SECTIONS and discovering 62 red rows.
assert.deepStrictEqual(
findMissingRosterRows(emptySections(), {
workflow_steps: ['quick/steps/a.md', 'quick/steps/b.md'],
workflow_modes: ['discuss-phase/modes/default.md'],
}),
{ missingSections: [], missingRows: [] },
);
});
test('the failure message names every missing path and the remedy that satisfies it', () => {
const rendered = formatRosterFailure({
missingSections: [],
missingRows: ['references/planner-coupling.md', 'cli_modules/hook-bus.cjs'],
});
assert.match(rendered, /references\/planner-coupling\.md/);
assert.match(rendered, /cli_modules\/hook-bus\.cjs/);
assert.match(
rendered,
/Regenerating the manifest does NOT satisfy this/,
'the predictable wrong guess is "run the generator"; the message has to close that door',
);
});
test('property — a command is reported missing exactly when no Source link names its file', () => {
// The cell-equality property below covers the five families that share one rule.
// `commands` is the family with its OWN rule, and therefore the one where an
// untested edge is most likely — so it gets its own property rather than riding on
// the five hand-written command fixtures.
const stemArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,10}$/);
fc.assert(
fc.property(
fc.uniqueArray(stemArb, { minLength: 1, maxLength: 8 }),
fc.uniqueArray(stemArb, { maxLength: 8 }),
(linked, candidates) => {
const absent = candidates.filter((c) => !linked.includes(c));
// Render each row with a DISPLAY NAME that is deliberately not the file stem,
// mirroring the six real namespace routers: if the matcher ever falls back to
// the rendered name, this property fails.
const rows = linked
.map((s) => '| `/gsd-alias-' + s + '` | role | [src](../commands/gsd/' + s + '.md) |')
.join('\n');
const text = emptySections({ Commands: '| Command | Role | Source |\n|---|---|---|\n' + rows });
const { missingRows } = findMissingRosterRows(text, {
commands: [...linked, ...absent].map((s) => '/gsd-' + s),
});
assert.deepStrictEqual(
missingRows.slice().sort(),
absent.map((s) => 'commands//gsd-' + s).sort(),
);
},
),
);
});
test('property — an entry is reported missing exactly when no cell in its section equals it', () => {
const nameArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,10}\.md$/);
fc.assert(
fc.property(
fc.uniqueArray(nameArb, { minLength: 1, maxLength: 8 }),
fc.uniqueArray(nameArb, { maxLength: 8 }),
(rostered, candidates) => {
// `candidates` may overlap `rostered`; the entries genuinely absent from the
// table are exactly the set difference, and that is what the matcher must
// return — no more (false red) and no fewer (false pass).
const absent = candidates.filter((c) => !rostered.includes(c));
const rows = rostered.map((n) => '| `' + n + '` | role |').join('\n');
const text = emptySections({ References: '| Reference | Role |\n|---|---|\n' + rows });
const { missingRows } = findMissingRosterRows(text, {
references: [...rostered, ...absent],
});
assert.deepStrictEqual(
missingRows.slice().sort(),
absent.map((e) => 'references/' + e).sort(),
);
},
),
);
});