* enhance(#4139): Phase 3 — partition rules + the five checks ADR-4139 Decision 5, epic #4139 Phase 3. Issue #4403's own "Proposed behavior" section lists four checks; the ADR's Decision 5 and its own phase table ("partition rules + the five checks") list five — the same four plus "boundary moves are declared, ongoing". Same issue-vs-ADR drift Phase 2 hit on the detail.md vs detail/*.md layout: the ADR is the locked, reviewed document, so it wins. This PR implements all five. docs/PARTITION-RULES.md (new) is the partition-rules document: the partition rule itself, the protected-content list and <!-- gsd:protected --> sentinel syntax (relocated unchanged from gsd-core/references/compact-content-protected-content.md, now deleted — it was never referenced by any runtime workflow Read, only by the predecessor test as documentation, so nothing at runtime regresses, and removing it from gsd-core/references/ also drops it from all 19 installed-project shipped-content trees for a file nothing ever read), and the five checks explained for a human reader. Referenced from a new CONTRIBUTING.md subsection under "Editing shipped content". tests/helpers/compact-content-split.cjs (new) is the shared mechanics: split discovery (any gsd-core/workflows/<name>/detail/*.md paired with <name>.md — no registry file, a pair is registered by existing on disk), line normalization (carries forward Phase 2's bare-label-line isTrivial fix and the canonical gsd_run-launcher-preamble exclusion), sentinel extraction, and a Boundary-Move-Declared commit-trailer reader that is a direct structural port of tests/helpers/emitted-runtime.cjs's Emitted-Drift-Ack-Hash/-Growth trailer reader (ADR-3942) — same merge-base range, same fail-closed throw on an uncomputable range, same dedupe/conflict rules. tests/compact-content-partition-guard.test.cjs (new) is the actual guard, superseding tests/plan-phase-compact-split.test.cjs (deleted — its per-pair checks are now the general guard's job for plan-phase specifically). Checks 2 (disjointness) and 3 (registration + size cap) run unconditionally against every registered split. Checks 1 (completeness, fires once per split on the PR that introduces a new detail/ path), 4 (protected content — no trailer can ever excuse this one, unlike check 5) and 5 (boundary moves declared) are PR-diff-scoped against the resolved base ref and skip cleanly when there's nothing to compare (a fresh clone, no PR in flight) — a deliberate asymmetry from check 5's trailer reader, which must throw rather than silently pass when ITS range is uncomputable, since that function is answering "did this PR declare its moves" rather than "is there even a diff to look at". Each of the five checks carries a RED (deliberately broken fixture) / GREEN (fixed) test pair, built against synthetic temp files or real throwaway git repos, per this repo's rule that a guard nobody has seen go red is not yet a guard. Building the real fixtures caught and fixed one real bug before it shipped: check 4's line-presence test was using the trivial-line-filtered normalizer, so a byte-identical spine falsely reported its own protected code-fence line as "deleted" — fixed with a non-filtering membership check. Extending docs/INVENTORY.md's "Workflow Sub-Files" table for `detail` surfaced a pre-existing, unrelated gap in the SAME area: gsd-core/workflows/<name>/templates/*.md is a fourth workflow sub-file kind that already existed on disk and was already known to lint-response-language-coverage.cjs's FRAGMENT_DIRS, but was invisible to gen-inventory-manifest.cjs and undocumented in that table. Fixed alongside it, same pattern, same PR, rather than deferred. Also, mechanically required by the new fourth sub-file kind: - scripts/lint-response-language-coverage.cjs: `detail` added to FRAGMENT_DIRS alongside modes/steps/templates — a detail/<part>.md inherits its parent's response_language coverage through the same per-file proof, not a parallel one. - tests/workflow-size-budget.test.cjs: explicit regression test locking that detail/ files are governed solely by the hard, non-waivable NEW_FILE_CAP (tests/helpers/emitted-diff.cjs) and never by the XL/LARGE/DEFAULT spine tiers — true by construction (measureWorkflows/listWorkflowStems don't recurse), made explicit per the issue's own Done-when item rather than left true-by-omission. - scripts/gen-inventory-manifest.cjs: `workflow_detail` and `workflow_templates` NESTED_FAMILIES entries; docs/INVENTORY-MANIFEST.json regenerated (plan-phase/detail/elaboration.md, discuss-phase/templates/*.md now tracked); docs/INVENTORY.md's table updated to four kinds. Verified: `npm run lint:ci` clean with the eslint cache cleared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4403): review findings + a real gsd-test failure in the new guard Two orthogonal review passes (Standards + Spec, isolated sub-agents) plus a separate security review ran against the prior commit. Fixed everything each surfaced: - Security (Low, path-traversal existence oracle): checkRegistration's dangling-reference check extracted detail-path-shaped substrings from spine PROSE via a regex that permits `.`/`/` freely, then joined them onto repoRoot and probed fs.existsSync with no containment check — a spine file containing `../../../etc/detail/passwd.md`-shaped text could make the guard test file existence outside the repo. Added a path.relative-based containment check before the fs.existsSync call; anything that resolves outside repoRoot is now reported as a dangling reference directly, never probed on disk. - Standards (Boundary Coverage): the size-cap fixtures covered NEW_FILE_CAP and NEW_FILE_CAP-1 but not NEW_FILE_CAP+1 — added the third boundary-point case CLAUDE.md's TEST RULES require (limit-1/limit/limit+1). - Standards (Property-Based Testing): extractProtectedBlocks (a sentinel parser) and the new parseBoundaryMoveTrailerValues (a declare/dedupe/conflict parser, bijective-shaped) had no fast-check property test. Added three: a render/parse bijectivity property for the trailer parser (mirroring the exact ADR-3942 sibling test's alphabet/idiom), a dedupe-is-idempotent property for the same parser, and a well-formed-sentinel-round-trips property for extractProtectedBlocks. Then dispatched gsd-test on the resulting commit. It found a real bug the reviews couldn't have caught (none of them can run inside gsd-test's sandbox): checks 4/5's real-repo assertion failed against plan-phase's own split, reporting DISK_PLANS/#3218-comment lines as "undeclared boundary moves" — content Phase 2 (#4402) legitimately moved into detail/elaboration.md months before this PR's Boundary-Move-Declared mechanism existed to require a trailer for it. Root cause: `resolveBase()`'s own doc comment already documents that no `origin/*` remote-tracking ref exists inside the gsd-test sandbox container, and its fallback candidate (a bare `next` branch) can resolve to a point in history that predates an already-merged, already-reviewed split — making that split look "newly introduced" from the sandbox's vantage point. Check 1 (completeness) already scopes itself correctly to only genuinely-new detail paths (git diff status 'A'); checks 4 and 5 did not share that scoping, so a stale base made them re-litigate a settled split retroactively. Fixed by having checks 4/5 skip any split name check 1 already counted as newly-split — their own premise ("did an EXISTING split shed/undeclare something") does not apply to a split that is, from the resolved base's vantage point, brand new; that is check 1's domain alone. Verified locally (25/25 tests pass via a direct `node -e` require, since `node --test` is blocked in this repo) and via re-reasoning through the exact real-repo scenario the gsd-test failure showed. Also regenerated all 19 tests/fixtures/install-tree/*.json goldens — the prior commit's deletion of gsd-core/references/compact-content-protected-content.md was never reflected there, which is what golden-install-tree.test.cjs's other 19 failures in the same gsd-test run were. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4403): backfill changeset pr number to 4497 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4403): isolate codex-config.test.cjs into its own chunk, root-causing the Windows CI failure PR #4497's "full test (windows-latest, 24, shard 2/3)" job failed: run-tests killed chunk 3/8 at the 600s per-chunk backstop, with codex-config.test.cjs (weight 17.87, by far the chunk's dominant cost) packed alongside 39 other files. Traced, not assumed: - scripts/run-tests.cjs's own timeout-headroom comment for the OUTER per-shard timeout documents that "adding one test file reshuffled 115 of 268 unit files between shards" — shard/chunk composition is architecturally known to be unstable to single-file additions, which is exactly what this PR's own new tests/compact-content-partition-guard.test.cjs is. - A second comment, dated 2026-09-06 (one day before this PR, PR #4428's own CI), already documents the SAME chunk hitting the SAME 600s backstop with the SAME file (codex-config.test.cjs, "a genuinely MEASURED weight of 17.87 — not a stale-table miss") dominating it — the fix then was cutting the Windows per-chunk budget from 60 to 40. That cut clearly was not enough: two documented incidents in two days, at two different budget settings, both centered on one file that alone consumes ~45% of even the reduced Windows budget. - tests/test-timings.json's own header confirms its source data (test-events-linux-node22/24.jsonl) is Linux-only, and run-tests.cjs's own chunk-timeout diagnostic already prints "real Windows cost runs ~2.2x the recorded figure" — the packer's weight-balancing is working off data that is both stale (table last regenerated 2026-08-07) and known to underestimate the platform where the failure occurs. Given codex-config.test.cjs is disproportionately heavy AND every companion sharing its chunk is decided by a packing algorithm already documented as reshuffling unpredictably on any new file, tuning the shared budget a third time only moves the marginal line to wherever the next new file happens to land — it does not remove the gamble. Isolating codex-config.test.cjs into its own dedicated single-file chunk, unconditionally and on every platform, removes it at the source: the file never enters the pool packChunks balances, so no other file's packing changes, and no future single-file addition (mine or anyone else's) can silently reintroduce this exact failure by landing in its chunk. Extracted as a small pure function, partitionIsolatedFiles (mirroring this file's existing pattern of pulling packing/analysis logic out of main() for in-process unit coverage — see computeSweepProtectSet, analyzeChunkEvents), with 6 new tests in tests/run-tests-harness.test.cjs covering basename matching across path separators, near-miss non-matches, the empty-list case, and the isolated-set contents. Root cause is now closed rather than papered over with a retry: this failure is a property of one specific heavy file's chunk placement, not something that recurs randomly. If codex-config.test.cjs itself is ever genuinely sped up, this isolation can be revisited — this is a packing-side mitigation for a known file's cost, not a claim the cost is irreducible. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 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 four NESTED families — `workflow_steps`, `workflow_modes`, `workflow_detail`,
|
|
* `workflow_templates`. `docs/INVENTORY.md` §"Workflow Sub-Files" is an explicit shipped
|
|
* decision that these carry no hand-written per-file rows: "Adding a step, mode, detail,
|
|
* or template 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 four 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,
|
|
};
|