* 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>
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* inventory-roster.cjs — the pure half of the `docs/INVENTORY.md` roster gate (#3762).
|
|
*
|
|
* `docs/INVENTORY-MANIFEST.json` is anchored against the filesystem by
|
|
* `tests/inventory-manifest-sync.test.cjs`. This module supplies the second
|
|
* comparison that test runs: every manifest entry must also have a hand-written row
|
|
* in `docs/INVENTORY.md`, which calls itself "Authoritative roster of every shipped
|
|
* GSD surface" (`docs/INVENTORY.md:3`) and promises "a new file without a matching
|
|
* row here will fail CI" (`docs/INVENTORY.md:681`). Until #3762 neither claim was
|
|
* true: PR #3758 shipped `gsd-core/references/planner-coupling.md` with a manifest
|
|
* entry, no roster row, and fully green CI.
|
|
*
|
|
* Everything here is PURE — text and the manifest object in, findings out, no
|
|
* filesystem access — so the matcher is drivable by fixture. A drift guard that can
|
|
* only be exercised against a clean repo proves nothing about its own comparison.
|
|
*
|
|
* ── SCOPE ──────────────────────────────────────────────────────────────────────
|
|
*
|
|
* IN: the SIX FLAT families — `agents`, `commands`, `workflows`, `references`,
|
|
* `cli_modules`, `hooks`. `docs/INVENTORY.md:7` says the file "enumerates every
|
|
* shipped surface across all six families", and each has its own `##` section.
|
|
*
|
|
* OUT: the two NESTED families — `workflow_steps`, `workflow_modes`.
|
|
* `docs/INVENTORY.md` §"Workflow Sub-Files" is an explicit shipped decision that
|
|
* these carry no hand-written per-file rows: "Adding a step or mode file requires no
|
|
* hand-written row here … The per-file roster deliberately lives in
|
|
* `docs/INVENTORY-MANIFEST.json` rather than being duplicated in this table — 60
|
|
* rows that must be hand-maintained in lockstep with a generated artifact is the
|
|
* drift this file exists to catch." Demanding rows there would override that
|
|
* decision, not enforce the roster.
|
|
*
|
|
* ── WHAT COUNTS AS A ROW ───────────────────────────────────────────────────────
|
|
*
|
|
* Deliberately narrow, because this runs on every PR and must tolerate formatting it
|
|
* has no business policing:
|
|
*
|
|
* • Each family is searched ONLY inside its own `##` section. A cell in another
|
|
* family's section does not count — `smart-entry.md` (a workflow) and
|
|
* `smart-entry.cjs` (a CLI module) are different surfaces. A MISSING section is
|
|
* reported as one structural failure, never as one phantom row per entry.
|
|
* • Matching is on a whole table CELL equal to the manifest entry after stripping
|
|
* WRAPPING backticks — never a substring. The real roster's
|
|
* `host-integration-adapters/imperative-hook-bus.cjs` row must not satisfy the
|
|
* separate top-level `hook-bus.cjs` entry, which is genuinely unrostered.
|
|
* • Subsections (`###`), column count, column order, CRLF, and trailing HTML
|
|
* comments (`| … |<!-- gsd-allow-legacy-name -->`) are all irrelevant.
|
|
* • COMMANDS MATCH ON THEIR SOURCE LINK, not their rendered name. Six namespace
|
|
* routers deliberately render `/gsd-workflow` while sourcing
|
|
* `commands/gsd/ns-workflow.md`; the display value is not the file identity.
|
|
* `docs/INVENTORY.md:63` documents every command row as carrying "a link to the
|
|
* source file", so the link is both the documented shape and the only
|
|
* collision-free key.
|
|
*
|
|
* Restructuring `docs/INVENTORY.md`'s `##` headings, or dropping the command Source
|
|
* column, is what ROSTER_SECTIONS below has to be updated for — in the same change.
|
|
*/
|
|
|
|
/**
|
|
* Manifest family name → the level-2 heading in `docs/INVENTORY.md` that rosters it.
|
|
* The two nested families are absent BY DESIGN (see above).
|
|
*/
|
|
const ROSTER_SECTIONS = Object.freeze({
|
|
agents: 'Agents',
|
|
commands: 'Commands',
|
|
workflows: 'Workflows',
|
|
references: 'References',
|
|
cli_modules: 'CLI Modules',
|
|
hooks: 'Hooks',
|
|
});
|
|
|
|
/** A manifest `commands` entry (`/gsd-ns-workflow`) → its source basename (`ns-workflow.md`). */
|
|
function commandSourceBasename(entry) {
|
|
return entry.replace(/^\/gsd-/, '') + '.md';
|
|
}
|
|
|
|
/**
|
|
* Split `text` into a Map of `heading → lines[]` keyed by level-2 (`##`) heading.
|
|
* Level-3 headings stay INSIDE their parent section, so a family's rows are found
|
|
* regardless of which `###` subsection a contributor files them under. Split on
|
|
* `\r?\n`: a Windows checkout is a supported working tree, and a `\n`-only split
|
|
* would leave a trailing `\r` on every cell and red the whole gate there.
|
|
*
|
|
* Fenced code blocks (``` or ~~~) are skipped wholesale — neither their headings
|
|
* nor their pipe-delimited lines participate. `docs/INVENTORY.md` carries no fence
|
|
* today, which is exactly why this is worth writing down: the day someone documents
|
|
* a `## ` example inside one, an unfenced scanner would silently truncate a family
|
|
* section and red the gate on a document that is perfectly correct.
|
|
*
|
|
* The heading pattern is deliberately `(.*)` + trim rather than a lazy `(.+?)` with
|
|
* a trailing `[ \t]*$`: the lazy form backtracks quadratically on a heading line
|
|
* padded with thousands of spaces, and this gate parses a file any contributor can
|
|
* write.
|
|
*
|
|
* Both the heading and the fence marker allow CommonMark's LEADING INDENT of up to
|
|
* three spaces. Anchoring hard at `^##` looks harmless and is not: an author who
|
|
* indents a heading by one space writes a perfectly valid document that this gate
|
|
* would read as having NO family sections at all, reporting all six as missing —
|
|
* a structural red for zero real drift.
|
|
*/
|
|
function splitLevel2Sections(text) {
|
|
const sections = new Map();
|
|
let current = null;
|
|
let fence = null;
|
|
for (const line of String(text).split(/\r?\n/)) {
|
|
const fenceMark = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
if (fenceMark) {
|
|
if (fence === null) {
|
|
fence = fenceMark[1][0];
|
|
continue;
|
|
}
|
|
if (fenceMark[1][0] === fence) fence = null;
|
|
continue;
|
|
}
|
|
if (fence !== null) continue;
|
|
|
|
const m = /^ {0,3}##[ \t]+(.*)$/.exec(line);
|
|
if (m) {
|
|
current = m[1].trim();
|
|
if (!sections.has(current)) sections.set(current, []);
|
|
continue;
|
|
}
|
|
if (current !== null) sections.get(current).push(line);
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
/**
|
|
* Peel the presentation off a whole table cell, one layer at a time, and return
|
|
* every form encountered — the raw text and each unwrapped result.
|
|
*
|
|
* Two layers are recognized, and only when they wrap the cell ENTIRELY:
|
|
* • a code span: `` `x.md` `` → `x.md`
|
|
* • a markdown link: `` [`x.md`](../x.md) `` → `` `x.md` `` → `x.md`
|
|
*
|
|
* ENTIRELY is the whole safety property. A role cell reading "superseded by
|
|
* `ghost.md`" keeps its text intact, so a file merely MENTIONED in prose can never
|
|
* masquerade as a row — which is the false PASS this gate exists to prevent, and is
|
|
* strictly worse than the false red that motivated handling the link form. The link
|
|
* text may not itself contain `]`, so a cell holding two links degrades to "no
|
|
* unwrap" rather than to a garbled middle substring.
|
|
*/
|
|
function unwrapCellForms(cell) {
|
|
const forms = [cell];
|
|
let current = cell;
|
|
// At most two peels: link → code span. Bounded, so no pathological input can
|
|
// turn this into a loop.
|
|
for (let i = 0; i < 2; i++) {
|
|
const code = /^`(.+)`$/.exec(current);
|
|
if (code) {
|
|
current = code[1].trim();
|
|
forms.push(current);
|
|
continue;
|
|
}
|
|
const link = /^\[([^\]]+)\]\([^)]*\)$/.exec(current);
|
|
if (link) {
|
|
current = link[1].trim();
|
|
forms.push(current);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return forms;
|
|
}
|
|
|
|
/**
|
|
* Every whole table cell in `lines`, trimmed, plus the unwrapped forms of each (see
|
|
* `unwrapCellForms`). Only cells are collected — a line that is not a table row
|
|
* contributes nothing.
|
|
*/
|
|
function tableCells(lines) {
|
|
const cells = new Set();
|
|
for (const line of lines) {
|
|
if (!line.trimStart().startsWith('|')) continue;
|
|
for (const raw of line.split('|')) {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) continue;
|
|
for (const form of unwrapCellForms(trimmed)) cells.add(form);
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
/**
|
|
* Basenames of every `commands/gsd/<file>.md` markdown-link target in `lines`,
|
|
* regardless of how many `../` hops the relative path takes or whether it carries a
|
|
* `#anchor` / `?query`.
|
|
*/
|
|
function commandSourceLinks(lines) {
|
|
const basenames = new Set();
|
|
for (const line of lines) {
|
|
const linkRe = /\]\(([^)\s]+)/g;
|
|
let m;
|
|
while ((m = linkRe.exec(line)) !== null) {
|
|
const target = m[1].split('#')[0].split('?')[0];
|
|
const hit = /(?:^|\/)commands\/gsd\/([^/]+\.md)$/.exec(target);
|
|
if (hit) basenames.add(hit[1]);
|
|
}
|
|
}
|
|
return basenames;
|
|
}
|
|
|
|
/**
|
|
* Compare `docs/INVENTORY.md`'s rows against the manifest's family arrays.
|
|
*
|
|
* @param {string} inventoryText contents of `docs/INVENTORY.md`
|
|
* @param {object} families the manifest's `families` object
|
|
* @returns {{missingSections: string[], missingRows: string[]}}
|
|
* `missingRows` entries read `"<family>/<entry>"`, matching the phrasing the
|
|
* manifest half of the gate already uses for additions/removals.
|
|
*/
|
|
function findMissingRosterRows(inventoryText, families) {
|
|
const sections = splitLevel2Sections(inventoryText);
|
|
const missingSections = [];
|
|
const missingRows = [];
|
|
|
|
for (const [family, heading] of Object.entries(ROSTER_SECTIONS)) {
|
|
const lines = sections.get(heading);
|
|
if (lines === undefined) {
|
|
missingSections.push(heading);
|
|
continue;
|
|
}
|
|
const entries = (families || {})[family] || [];
|
|
if (family === 'commands') {
|
|
const links = commandSourceLinks(lines);
|
|
for (const entry of entries) {
|
|
if (!links.has(commandSourceBasename(entry))) missingRows.push(family + '/' + entry);
|
|
}
|
|
continue;
|
|
}
|
|
const cells = tableCells(lines);
|
|
for (const entry of entries) {
|
|
if (!cells.has(entry)) missingRows.push(family + '/' + entry);
|
|
}
|
|
}
|
|
|
|
return { missingSections, missingRows };
|
|
}
|
|
|
|
/** Human-readable remediation for a non-empty `findMissingRosterRows` result. */
|
|
function formatRosterFailure({ missingSections, missingRows }) {
|
|
return [
|
|
missingSections.length
|
|
? 'docs/INVENTORY.md is missing a "## <heading>" section this gate rosters against:\n' +
|
|
missingSections.map((h) => ' ## ' + h).join('\n') +
|
|
'\nIf the section was deliberately renamed or merged, update ROSTER_SECTIONS in\n' +
|
|
'tests/helpers/inventory-roster.cjs in the same change.'
|
|
: '',
|
|
missingRows.length
|
|
? 'Shipped surfaces in docs/INVENTORY-MANIFEST.json with NO row in docs/INVENTORY.md (#3762):\n' +
|
|
missingRows.map((e) => ' ! ' + e).join('\n') +
|
|
'\nAdd a row to the matching "## " section of docs/INVENTORY.md: the file name in a cell,\n' +
|
|
'plus the one-line role that section\'s table asks for. For a command, the row\'s Source\n' +
|
|
'column must link to ../commands/gsd/<file>.md — that link, not the rendered /gsd-name,\n' +
|
|
'is what this gate reads. Regenerating the manifest does NOT satisfy this: the roster row\n' +
|
|
'is hand-written by design, because a role sentence cannot be generated.'
|
|
: '',
|
|
].filter(Boolean).join('\n\n');
|
|
}
|
|
|
|
// Only the three names the gate actually consumes are exported. The parsing
|
|
// helpers above stay module-private on purpose: exporting them would create a
|
|
// surface nothing calls, and every one of their behaviors is already pinned
|
|
// through `findMissingRosterRows` by the fixture rows in
|
|
// `tests/inventory-manifest-sync.test.cjs` — asserting them directly would test
|
|
// the implementation instead of the contract.
|
|
module.exports = {
|
|
ROSTER_SECTIONS,
|
|
findMissingRosterRows,
|
|
formatRosterFailure,
|
|
};
|