Files
msd-core/tests/mcp-catalog-parity.install.test.cjs
Tom Boucher 2e2b8ba4a7 enhance(#2704): resolve documentation links and compare H1 status brackets in the ADR gate (#3266)
* test(#2704): failing-first coverage for ADR link resolution and H1 status brackets

Binds the gate to two assertions it does not yet make: every relative markdown
link under docs/adr/ must resolve, and an H1 trailing status bracket must agree
with the Status: field instead of being silently stripped.

Covers all 51 rows of the phase test matrix across two altitudes - the pure
extractLinks/maskCode IR for fence and inline-code-span boundaries, hostile
input and the fast-check totality properties, and the real CLI verdict for the
end-to-end classes. Includes the DEFECT.GENERATIVE-FIX parity test that iterates
the exported STATUSES array so a sixth status is covered the day it is added.

* feat(#2704): resolve ADR documentation links and compare H1 status brackets

The ADR gate validated naming, relation symmetry and index freshness but never
resolved a link target, and it stripped an ADR's trailing H1 status bracket for
display rather than comparing it against that ADR's own Status: field. Both
classes were structurally invisible: #2691 found five dangling references by
manual audit roughly a year after they were introduced, one of which reached the
published npm payload, while CI reported green throughout.

Both are now assertions on the same --check path, using only node:fs and
node:path - no dependency and no subprocess.

Fenced blocks and inline code spans are masked before scanning, because markdown
does not render a link inside code. That is not a policy choice: the corpus
contains exactly two such sequences today and both are ordinary JavaScript.
Masking preserves length and column positions so findings still name a real line.

Resolution is case-exact on every platform - a link that resolves only through
macOS or Windows case-folding still 404s on github.com and still fails the Linux
lane - and a destination resolving outside the repository is reported before any
filesystem call is made.

Also single-sources two duplicated surfaces this change would otherwise have
extended: the H1 bracket vocabulary (a second hand-written copy of STATUSES with
nothing asserting agreement, a DEFECT.GENERATIVE-FIX instance) and the docs/adr
directory traversal. Two tests added by #2691 that reimplemented link resolution
and bracket comparison inside the test file are removed for the same reason; the
corpus assertion is now made by running the real gate against the real corpus.

* fix(#2704): reject symlinks that leave the repository and linearize code masking

Four defects from the isolated adversarial security review, plus one it noted.

BLOCKER - a symlink defeated path containment. path.relative(ROOT, abs) is
purely lexical, but the case-exact walk then calls readdirSync, which follows
symlinks at the OS level: a contributor-committed docs/adr/x -> /etc together
with a link through it passed containment and listed the real external
directory, and a wrong-case probe echoed a real external filename through the
"Did you mean" hint into publicly-readable fork-PR logs. Every segment is now
lstat'd before descent; a symlink is realpathed and re-checked against
realpath(ROOT) - realpath on both sides, so a root under /var does not produce
false escapes - and an escape emits no hint and reads nothing further.

The same rule now governs which FILES are read: an ADR entry that is a symlink
out of the repository is excluded and reported rather than parsed, closing the
vector this change had widened by newly reading README.md, naming-violation
files, and full bodies rather than only header fields.

MAJOR - inline-span masking rescanned the line remainder per backtick run,
roughly O(n^1.6) on adversarial input: 1.76s for an 800KB line. Rewritten as a
single linear pass pairing runs through forward-only per-length cursors. Same
input now takes 3.31ms, with behavior unchanged.

MINOR - an unreadable or broken entry threw, and the generic handler wrote a
raw stack trace carrying absolute CI paths to stderr. The scan is now
fault-tolerant and reports excluded entries as ordinary violations. The status
vocabulary is escaped before being interpolated into a dynamic RegExp -
defence-in-depth, not a live bug.

The containment predicate had reached three hand-written copies while fixing
this; it is now the single escapesRoot() helper used by all four call sites.

* feat(#2704): add a --json report so the gate's tests assert on typed values

Maintainer-directed addition. CONTRIBUTING.md's "Prohibited: Raw Text Matching
on Test Outputs" requires that a system under test producing text also expose a
structured intermediate representation, and that tests assert on that IR rather
than on rendered prose. This gate had no such surface, so its verdict tests
matched on stderr.

--json runs exactly the same validation as --check and writes a report to stdout
with the same exit code, following the frozen-REASON-enum pattern already used
by verify-reapply-patches.cjs. Every violation carries a stable reason code plus
the fields a consumer needs, so nothing has to pattern-match an error message.
Adding a reason stays three coordinated changes - the enum, the emitting site,
and the test locking Object.keys(REASON).sort().

The human output is unchanged, deliberately: a large pre-existing suite asserts
on it and migrating that is not this PR's concern. Verified by running the
pre-change and post-change scripts against an identical violating corpus and
diffing their stderr - character-for-character identical.

This PR's own verdict tests now assert on parsed --json. Absence checks improve
the most: "no bracket violation" is now a reason-code predicate rather than a
negative regex over prose, which could pass for the wrong reason. The security
assertions were strengthened rather than translated - no leaked filename may
appear in ANY field of the serialized report.

Unknown flags are now rejected instead of silently falling through to printing
the index.

* test(#2704): fix the status-parity fixture and guard hooks/dist before overlay builds

Two failures from the matrix run of 79b29909.

The status-parity fixture was mine. It built, per status token, an ADR whose H1
bracket and Status field both carried that token - but Superseded carries an
obligation beyond the bracket: it must name its successor as a file link and be
symmetric with it. The fixture declared a bare Superseded, tripped that
unrelated invariant, and the test reported a bracket-parity failure for a reason
that had nothing to do with bracket parity. The fixture now satisfies each
token's own obligations in both the agreeing and contradicting corpora, derived
from the status actually declared rather than special-cased on one name, so a
future token carrying obligations is handled rather than silently skipped.

The second failure was not mine but is fixed here rather than deferred.
mcp-catalog-parity.install.test.cjs hardlinks hooks/dist/* while building its
overlay, but hooks/dist is a gitignored build artifact produced only by
build:hooks. The suite had no guard, so it passed only when some other suite
happened to build it first - an execution-order dependency, which is why it
failed on node22 and passed on node24 for identical code. install.test.cjs
already documents this exact hazard and guards it.

Six behaviorally identical copies of that guard existed across three files.
Rather than add a seventh, they are now one canonical
tests/helpers/hooks-dist.cjs - idempotent and bounded by the shared
BUILD_TIMEOUT_MS class norm - which is the same single-sourcing this PR applies
to the ADR gate itself.

* docs(#2704): add a how-to for contributors the ADR gate rejects

The reference and explanation quadrants were covered by Lifecycle rules 5 and 6,
but the task-oriented one was thin: a contributor meets this gate because it
failed on their PR, under pressure, and the rules told them what is checked
without telling them what to do about it.

Adds the command to reproduce the CI failure locally and a message-to-remedy
table covering every reason code that can be hit - unresolved target, wrong case
with the did-you-mean hint, repository escape, symlinked ADR file, bracket
contradiction - plus the backtick escape hatch for illustrative links and the
caveat that indented code blocks are not skipped.

The table is itself written in backticked inline code, so the gate skips it: the
escape hatch demonstrated on the page that documents it.

* chore(#2704): backfill changeset PR number

pr:0 placeholder replaced with the real PR number now that #3266 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-09 17:08:46 -04:00

342 lines
16 KiB
JavaScript

'use strict';
/**
* mcp-catalog-parity.install.test.cjs — the anti-drift gate mandated by
* ADR-1671 ("Dual-surface drift if any future MCP channel is added —
* requires parity assertions"), issue #3072 (epic #1671 Phase B),
* `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md` "The parity
* assertion".
*
* ## PRIOR DEFECT (review BLOCKER, fixed by this rewrite)
*
* The original `tests/mcp-catalog-parity.test.cjs` never imported, spawned,
* or otherwise exercised `bin/install.js`. It recomputed the "installer
* side" by calling `shouldCompose`/`composeWorkflow` directly — the SAME
* functions the catalog itself calls — so it only proved `src/mcp-catalog.cts`
* is self-consistent with itself. Its row-52 assertion compared
* `shouldCompose` against a regex literal frozen inside the test file, never
* against `bin/install.js`'s real behavior. Net effect: a re-introduced,
* divergent inline composition-scope regex in `bin/install.js` (the exact
* regression `ADR-1671:309` `DEFECT.GENERATIVE-FIX` warns about) would have
* stayed GREEN.
*
* This file instead drives a REAL spawned `bin/install.js` (via
* `tests/helpers/install-shared.cjs`'s `runMinimalInstall` — the same driver
* `tests/workflow-fragments-emission.install.test.cjs` and
* `tests/agent-fragments-emission.install.test.cjs` use) and compares its
* REAL emitted output against the catalog's served content, renamed to
* `.install.test.cjs` to land in the slow `install` suite (`npm run
* test:install`) alongside those files.
*
* ## Why marker PRESENCE, not byte-equality
*
* `bin/install.js` applies per-runtime path rewrites (`~/.claude/` -> the
* runtime's prefix, attribution stamping, per-runtime `.md` converters)
* AFTER composition (`shouldCompose`/`composeWorkflow`, just below in
* `copyWithPathReplacement`). The catalog is host-agnostic and applies none
* of those rewrites. Raw byte-equality between an emitted file and served
* content is therefore FALSE BY CONSTRUCTION for every file whose content
* embeds a rewritten path or attribution stamp. What DOES survive every
* rewrite untouched is the COMPOSITION DECISION itself: did this file's
* `<!-- gsd:section` markers get stripped or not? Marker-token presence is
* that observable (the same detector
* `tests/workflow-fragments-emission.install.test.cjs`'s
* `noSectionMarkerLeaksIntoEmittedArtifacts` already uses), and it is
* insensitive to every rewrite that runs after composition.
*
* ## Single representative runtime
*
* `shouldCompose`/`composeWorkflow` do not depend on runtime — only the
* REWRITES applied after composition do, and this gate's marker-presence
* comparison is deliberately blind to those. A multi-runtime loop would
* therefore just re-run the identical composition decision N times; one real
* spawned install (`claude`, the canonical host per `src/mcp-catalog.cts`'s
* own module doc) is sufficient real-install evidence for what this gate
* checks, and keeps this already-slow suite bounded.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { cleanup } = require('./helpers.cjs');
const { runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs');
const { buildOverlayRepo } = require('./helpers/overlay-repo.cjs');
const { ensureHooksDist } = require('./helpers/hooks-dist.cjs');
const { buildCatalog, readResource, shouldCompose } = require('../gsd-core/bin/lib/mcp-catalog.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const MARKER_TOKEN = 'gsd:section';
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. This
// suite's real spawned install (runMinimalInstall) and overlay builds
// (buildOverlayRepo, which hard-links every leaf under REPO_ROOT including
// hooks/dist/) both need it populated. In CI the scoped/windows jobs do not
// run build:hooks first, so — absent this guard — the suite only passes when
// some OTHER suite happened to build hooks/dist first (see #2704 Failure B).
before(() => {
ensureHooksDist();
});
/** Recursively collect `.md` file paths under `absDir`, relative to `REPO_ROOT`, POSIX-normalized. */
function collectMarkdownFiles(absDir, out = []) {
let entries;
try {
entries = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const abs = path.join(absDir, entry.name);
if (entry.isDirectory()) {
collectMarkdownFiles(abs, out);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
out.push(path.relative(REPO_ROOT, abs).replace(/\\/g, '/'));
}
}
return out;
}
function realParitySet() {
return [
...collectMarkdownFiles(path.join(REPO_ROOT, 'gsd-core', 'workflows')),
...collectMarkdownFiles(path.join(REPO_ROOT, 'gsd-core', 'references')),
];
}
/** `gsd-core/workflows/x.md` -> `gsd://workflows/x.md`; `gsd-core/references/y.md` -> `gsd://references/y.md`. */
function toResourceUri(relPath) {
const m = /^gsd-core\/(workflows|references)\/(.+)$/.exec(relPath);
if (!m) throw new Error(`fixture bug: unexpected relPath shape ${relPath}`);
return `gsd://${m[1]}/${m[2]}`;
}
function hasMarker(text) {
return text.includes(MARKER_TOKEN);
}
/**
* Spawn a (possibly overlaid) `bin/install.js` at global scope and assert it
* succeeded. Mirrors `workflow-fragments-emission.install.test.cjs`'s and
* `fragment-single-edit-propagation.install.test.cjs`'s own `spawnGlobalInstall`
* / `installOverlay` — kept local per those files' own documented rationale:
* it is a thin spawn wrapper with no independent mechanism (unlike
* `buildOverlayRepo`, which IS imported/reused), so a local copy carries none
* of the "generative fix divergence" risk.
*/
function installOverlay(overlayRoot, runtime, extraArgs = []) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-3072-parity-dest-${runtime}-`));
const installScript = path.join(overlayRoot, 'bin', 'install.js');
const args = [
'--preserve-symlinks',
'--preserve-symlinks-main',
installScript,
`--${runtime}`,
'--global',
'--config-dir',
root,
...extraArgs,
];
const result = runNode(args, {
cwd: root,
env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: INSTALL_TIMEOUT_MS,
});
return { configDir: root, root, result };
}
// ─── row 48 — the real gate ─────────────────────────────────────────────────
describe('the parity gate — real installer vs real catalog', () => {
test('installer composition decision matches the served catalog for every file in the real installed tree (row 48/52)', (t) => {
const catalog = buildCatalog({ root: REPO_ROOT });
const install = runMinimalInstall({ runtime: 'claude', scope: 'global' });
t.after(() => cleanup(install.root));
// Anti-vacuity guard: the installer must actually have emitted files —
// proves the spawned install really ran (not a silent no-op / early exit).
const emittedFileCount = install.manifest && install.manifest.files
? Object.keys(install.manifest.files).length
: 0;
assert.ok(
emittedFileCount > 0,
'the installer must have emitted at least one file — proves the real install actually ran',
);
const relPaths = realParitySet();
assert.ok(relPaths.length > 0, 'the real parity set must be non-empty');
let comparedCount = 0;
let markerBearingWorkflowSeen = false;
let nonComposedFileSeen = false;
for (const relPath of relPaths) {
const emittedPath = path.join(install.configDir, relPath);
const uri = toResourceUri(relPath);
if (!fs.existsSync(emittedPath) || !catalog.resources.has(uri)) continue; // outside this gate's install/catalog intersection
comparedCount += 1;
const sourceText = fs.readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
const emittedText = fs.readFileSync(emittedPath, 'utf8');
const servedText = readResource(catalog, uri).text;
const markersPresentInEmitted = hasMarker(emittedText);
const markersPresentInServed = hasMarker(servedText);
// row 48: the two independently-produced surfaces (a real spawned
// installer vs. the catalog's own composition) must agree on whether
// this file's markers were stripped.
assert.equal(
markersPresentInEmitted,
markersPresentInServed,
`${relPath}: composition decision diverged between the real installer (markers present=${markersPresentInEmitted}) and the served catalog (markers present=${markersPresentInServed})`,
);
// row 52 replacement: the installer's OBSERVABLE composition behavior
// (not a re-declared regex) must match shouldCompose's own verdict —
// composed files never retain markers; declined files are untouched.
const composedAccordingToPredicate = shouldCompose(relPath);
const expectedEmittedHasMarker = composedAccordingToPredicate ? false : hasMarker(sourceText);
assert.equal(
markersPresentInEmitted,
expectedEmittedHasMarker,
`${relPath}: real installer output does not match shouldCompose's verdict (shouldCompose=${composedAccordingToPredicate})`,
);
if (composedAccordingToPredicate && hasMarker(sourceText)) markerBearingWorkflowSeen = true;
if (!composedAccordingToPredicate) nonComposedFileSeen = true;
}
assert.ok(comparedCount > 0, 'the comparison set must be non-empty — else the gate proves nothing');
assert.ok(
markerBearingWorkflowSeen,
'the comparison set must include >=1 workflow that actually carries markers in source, else every comparison is a byte-identical no-op',
);
assert.ok(
nonComposedFileSeen,
'the comparison set must include >=1 file the predicate declines to compose, else a predicate that always returns true would still pass',
);
});
});
// ─── row 50 — a marker-documenting non-workflow composes on neither surface ─
//
// No real gsd-core/references/*.md or commands/gsd/*.md file documents the
// `<!-- gsd:section -->` marker syntax as of this change (verified: `grep -rl
// "gsd:section" gsd-core/references/ commands/gsd/` is empty). This test
// therefore uses a SYNTHETIC fixture: an overlay (`buildOverlayRepo`, the
// established technique — see `nonWorkflowMarkdownWithMarkerShapedLineIsNot
// Composed` in `workflow-fragments-emission.install.test.cjs`, which
// pioneered this exact fixture content) that overrides the real, existing
// `gsd-core/references/context-budget.md` leaf with a doc that documents
// marker syntax via a deliberately UNCLOSED marker-shaped example line — so
// if a regression ever ran composeWorkflow over it, parsing would THROW
// loudly (never silently mis-parse), which is what makes this a real
// negative control rather than a fixture that would coincidentally pass
// either way.
describe('a marker-documenting non-workflow composes on neither surface (row 50)', () => {
test('reference doc content is present verbatim in source, in the real installed tree, and in the catalog served over the same tree', (t) => {
const nonWorkflowDoc =
'# Marker syntax\n\nExample (deliberately unfenced and unclosed to prove non-composition):\n\n<!-- gsd:section id="x" when="always" -->\nnever closed on purpose\n';
const target = 'gsd-core/references/context-budget.md';
assert.equal(shouldCompose(target), false, 'precondition: a references/ path must never be composed');
const overlayRepo = buildOverlayRepo({ [target]: nonWorkflowDoc });
t.after(() => cleanup(overlayRepo));
const dest = installOverlay(overlayRepo, 'claude');
t.after(() => cleanup(dest.root));
assert.equal(
dest.result.exitCode,
0,
`install must succeed: a non-workflow doc's marker-shaped line must never reach composeWorkflow\nstderr: ${dest.result.stderr}`,
);
const emittedPath = path.join(dest.configDir, target);
assert.ok(fs.existsSync(emittedPath), 'emitted context-budget.md is missing');
assert.equal(
fs.readFileSync(emittedPath, 'utf8'),
nonWorkflowDoc,
'a marker-documenting reference doc must be emitted byte-identical by the real installer (never composed)',
);
// Catalog served over the SAME tree the installer just read from (the
// overlay), not REPO_ROOT — REPO_ROOT has no such fixture on disk.
const catalog = buildCatalog({ root: overlayRepo });
const servedText = readResource(catalog, toResourceUri(target)).text;
assert.equal(
servedText,
nonWorkflowDoc,
'a marker-documenting reference doc must be served byte-identical by the catalog (never composed)',
);
});
});
// ─── row 51 — the gate is non-vacuous against a REAL installer regression ──
//
// Simulates the exact regression class this gate exists to catch: a
// composition-scope predicate that diverges reaching the REAL bin/install.js
// emit path — not a hand-duplicated regex living only in this test file (the
// prior defect this rewrite fixes). Overlays
// `gsd-core/bin/lib/mcp-catalog.cjs`'s `shouldCompose` export — the ONE thing
// `bin/install.js` imports from that module (`const { shouldCompose } =
// require('../gsd-core/bin/lib/mcp-catalog.cjs')`) — with one that never
// composes anything, exactly what a reverted or independently-diverged
// inline regex in `bin/install.js` would produce. `composeWorkflow` itself
// is left untouched, so the install still succeeds; it simply never gets
// called for any file.
describe('the parity gate is non-vacuous against a real installer regression (row 51)', () => {
test('a broken shouldCompose reaching the real bin/install.js produces a detectable installer/catalog divergence', (t) => {
const brokenPredicateRepo = buildOverlayRepo({
'gsd-core/bin/lib/mcp-catalog.cjs': 'module.exports = { shouldCompose: () => false };\n',
});
t.after(() => cleanup(brokenPredicateRepo));
const dest = installOverlay(brokenPredicateRepo, 'claude');
t.after(() => cleanup(dest.root));
assert.equal(
dest.result.exitCode,
0,
`broken-predicate install must still succeed (composeWorkflow simply never runs)\nstderr: ${dest.result.stderr}`,
);
const target = 'gsd-core/workflows/autonomous.md';
const sourceText = fs.readFileSync(path.join(REPO_ROOT, target), 'utf8');
assert.ok(
hasMarker(sourceText),
'precondition: the fixture workflow must actually carry markers, or this simulation proves nothing',
);
const emittedPath = path.join(dest.configDir, target);
assert.ok(fs.existsSync(emittedPath), 'broken-predicate install is missing autonomous.md');
const emittedText = fs.readFileSync(emittedPath, 'utf8');
// The real, non-overlaid catalog is unaffected by the overlay — it still
// composes autonomous.md and strips its markers.
const catalog = buildCatalog({ root: REPO_ROOT });
const servedText = readResource(catalog, toResourceUri(target)).text;
assert.equal(hasMarker(emittedText), true, 'broken-predicate install unexpectedly composed anyway');
assert.equal(hasMarker(servedText), false, 'the real, non-overlaid catalog unexpectedly failed to compose');
// This is row 48's own assertion, run against a REAL spawned installer
// that regressed exactly the way ADR-1671:309 warns about: it must
// DIVERGE here, proving row 48 would have gone RED had this shipped.
assert.notEqual(
hasMarker(emittedText),
hasMarker(servedText),
'a broken installer-side predicate must produce a detectable emitted/served divergence, or row 48 would stay green through this exact regression',
);
});
});