fix(#2873): close review findings across fences, sanitizer and docs

Isolated security review found resolveSpecRootReference's fence tracker
toggled on any delimiter, so a backtick fence could be closed by a tilde
one and an include in the gap was rewritten inside a code block. Fixed by
reusing scanFencedBlocks - the canonical engine already behind
stripFencedCode and extractFencedBlock - rather than carrying a fourth
copy of fence detection, which also closes the duplication the standards
review flagged.

sanitizeForRender now strips combining marks and zero-width characters
alongside the ANSI, control and bidi classes it already handled.

Adds the C, E and F matrix rows the spec review found missing, including
installer-level coverage that spawns the real install rather than calling
the report builder. Ships the how-to, the reference and command docs in
five locales, the changeset, the inventory and glossary entries, and
regenerates health.md for the new W028 rule.

Refs #2873
This commit is contained in:
sim
2026-08-14 23:09:36 -04:00
parent 2641e6cb67
commit 147856040b
25 changed files with 747 additions and 34 deletions

View File

@@ -0,0 +1,44 @@
'use strict';
/**
* Preload fixture for #2873 matrix row C5 — force the installer's
* cross-scope shadow-report call site (bin/install.js, immediately after
* writeManifest) to exercise its own swallow-everything catch.
*
* `install-shadow-report.cjs`'s `buildShadowReport()` only degrades a
* `TypeError` thrown by `resolveInstalledSurfaces` (returns
* `RESOLVER_UNAVAILABLE`); any OTHER error type it lets propagate — see its
* own doc comment. This preload replaces the exported `buildShadowReport`
* with a function that always throws a plain `Error`, so whatever calls it
* next (`bin/install.js`, requiring the SAME resolved module path and
* therefore getting the SAME cached module object) sees that throw and must
* swallow it without failing the install (design row C5: "a report failure
* never fails the install").
*
* Loaded via `node --require <this file> bin/install.js ...`
* (`tests/helpers/process-seam.cjs`'s `runNode` forwards extra argv
* verbatim, ahead of the target script) so the patch lands in the require
* cache BEFORE `bin/install.js`'s own
* `require('../gsd-core/bin/lib/install-shadow-report.cjs')` resolves.
*
* One-shot subprocess: no restoration needed — the process exits right
* after the single `install()` call this preload targets. This is NOT a
* chmod/mode-bit trick (CONTRIBUTING.md: those no-op under root), and NOT
* the in-process fs fault-injection seam (`tests/helpers/faulty-deps.cjs`'s
* `withFaultyFs` is explicitly documented as in-process-only, since a
* spawned subprocess offers no shared memory to monkeypatch into) — this
* patches the ONE exported function the design doc names as the report
* builder, at the require-cache seam a spawned child process actually
* offers.
*/
const path = require('node:path');
const modPath = require.resolve(
path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'install-shadow-report.cjs'),
);
const shadowReportModule = require(modPath);
shadowReportModule.buildShadowReport = function throwingBuildShadowReport() {
throw new Error('injected by tests/helpers/shadow-report-throws-preload.cjs (#2873 matrix row C5)');
};

View File

@@ -33,6 +33,7 @@ const {
INSTALL_SCRIPT,
MANIFEST_NAME,
installerEnv,
stripAnsi,
} = require('./helpers/install-shared.cjs');
const {
@@ -6441,3 +6442,187 @@ describe('#2218 cross-scope shadowing', () => {
);
});
});
// ─── #2873 matrix section C — install-time report (spawned installer) ─────
//
// Implements rows C1-C6 from
// `.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`. The
// `#2218 cross-scope shadowing` suite above calls `buildShadowReport`
// DIRECTLY — real coverage of the pure IR, but it proves nothing about the
// INSTALLER'S OWN WIRING at bin/install.js's writeManifest-adjacent
// try/catch block (the only call site that ever prints a report). These
// rows instead spawn the real installer and inspect its own stdout/stderr
// and exit code — the actual product surface #2218 reported a gap in.
describe('#2873 C1-C6 — install-time shadow report (spawned installer wiring)', () => {
const { buildShadowReport, renderShadowReport } = require('../gsd-core/bin/lib/install-shadow-report.cjs');
const SHADOW_THROWS_PRELOAD = path.join(__dirname, 'helpers', 'shadow-report-throws-preload.cjs');
function spawnInstall(args, cwd, root, nodeFlags = []) {
return runNode([...nodeFlags, INSTALL_SCRIPT, ...args], {
cwd,
env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: INSTALL_TIMEOUT_MS,
});
}
/**
* Assert `stderr` (the installer's own `console.warn` shadow-report
* output) actually carries `expectedReport`'s rendered lines, verbatim.
* `expectedReport`/its lines are computed by CALLING the module's own
* pure `buildShadowReport`/`renderShadowReport` against the SAME on-disk
* fixture the spawned installer just produced — never a guessed/hardcoded
* literal. This is the typed-count-plus-content route the review brief
* asks for: structural comparison against a pure function's own computed
* output (mirrors this file's own `extractAtIncludeLines`/E14 pattern
* above), not prose matching.
*/
function assertReportRendered(stderr, expectedReport) {
const lines = renderShadowReport(expectedReport);
assert.ok(lines.length > 0,
'fixture must actually be shadowed for this to be a meaningful positive assertion');
const stripped = stripAnsi(stderr);
for (const line of lines) {
assert.ok(stripped.includes(line),
`expected installer stderr to carry the typed report line ${JSON.stringify(line)}\nstderr: ${stderr}`);
}
}
test('C1: global-then-local double install reports shadowing on the second install, exit 0', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c1-'));
const projectDir = path.join(root, 'myrepo');
fs.mkdirSync(projectDir, { recursive: true });
t.after(() => cleanup(root));
const g = spawnInstall(['--claude', '--global'], root, root);
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
const l = spawnInstall(['--claude', '--local'], projectDir, root);
assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`);
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
assert.strictEqual(expectedReport.shadowed, true);
assertReportRendered(l.stderr, expectedReport);
});
test('C2: local-then-global double install reports shadowing symmetrically, exit 0', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c2-'));
const projectDir = path.join(root, 'myrepo');
fs.mkdirSync(projectDir, { recursive: true });
t.after(() => cleanup(root));
const l = spawnInstall(['--claude', '--local'], projectDir, root);
assert.strictEqual(l.exitCode, 0, `local install failed: ${l.stdout}\n${l.stderr}`);
// The global install's own production `buildShadowReport(runtime)` call
// (bin/install.js) takes no injected opts — it defaults to
// `process.cwd()` to detect a coexisting LOCAL scope. Run it with cwd
// INSIDE the already-locally-installed project (the real #2218 shape: a
// developer running the global install from inside an existing
// project), or it structurally cannot see the local scope at all —
// verified empirically: cwd=root (a global install's usual cwd) never
// reports, cwd=projectDir does.
const g = spawnInstall(['--claude', '--global'], projectDir, root);
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
assert.strictEqual(expectedReport.shadowed, true);
assertReportRendered(g.stderr, expectedReport);
});
test('C3: global-only install stays quiet, exit 0 (negative proof)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c3-'));
t.after(() => cleanup(root));
const g = spawnInstall(['--claude', '--global'], root, root);
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
// Typed control: a single-scope fixture can never be shadowed by
// construction (buildShadowReport requires two installed scopes) —
// confirms this negative-proof fixture is not accidentally shadowed.
const controlReport = buildShadowReport('claude', { home: root, cwd: root });
assert.strictEqual(controlReport.shadowed, false);
assert.ok(
// allow-test-rule: negative proof over a spawned process's real stdio
// has no typed positive to structurally compare against
// (renderShadowReport returns [] for an unshadowed report, so there
// is nothing computed to search for). This is renderShadowReport's
// own FIXED template fragment — present in BOTH its kindsDiffer
// branches, verbatim in the module source — not a guessed literal.
// [#2873]
!stripAnsi(g.stderr).includes(' shadowed: the '),
`expected no shadow report in a single-scope install's stderr: ${g.stderr}`,
);
});
test('C4: an install that fails before writeManifest never emits a report', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c4-'));
t.after(() => cleanup(root));
// Structural failure, never chmod (chmod 0o000 no-ops under root —
// CONTRIBUTING.md): pre-create the global config dir's OWN path as a
// plain file. installerMigrations' lock-acquisition mkdirSync (which
// runs before ANY artifact copy, long before writeManifest at
// bin/install.js) then throws ENOTDIR/EEXIST — verified empirically,
// and works identically whether or not the test runner is root.
fs.writeFileSync(path.join(root, '.claude'), 'blocker');
const g = spawnInstall(['--claude', '--global'], root, root);
assert.notStrictEqual(g.exitCode, 0,
`expected the structural collision to fail the install: ${g.stdout}\n${g.stderr}`);
const manifestPath = path.join(root, '.claude', MANIFEST_NAME);
assert.ok(!fs.existsSync(manifestPath), 'writeManifest must never have run');
assert.ok(
// allow-test-rule: same fixed-template anchor as C3 — the failure
// path never reaches the report call site at all (it runs strictly
// after writeManifest), so this asserts the absence side of the same
// typed renderShadowReport contract. [#2873]
!stripAnsi(g.stdout + g.stderr).includes(' shadowed: the '),
`expected no shadow report emitted before a structural failure: ${g.stdout}\n${g.stderr}`,
);
});
test('C5: a throwing report builder never fails the install, report suppressed', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c5-'));
t.after(() => cleanup(root));
const g = spawnInstall(['--claude', '--global'], root, root, ['--require', SHADOW_THROWS_PRELOAD]);
assert.strictEqual(g.exitCode, 0,
`a throwing buildShadowReport must never fail the install: ${g.stdout}\n${g.stderr}`);
const manifestPath = path.join(root, '.claude', MANIFEST_NAME);
assert.ok(fs.existsSync(manifestPath),
'writeManifest must still have run — the report call happens strictly after it');
assert.ok(
// allow-test-rule: same fixed-template anchor as C3/C4 — the injected
// throw is caught before renderShadowReport ever runs, so no report
// text should reach stderr; there is no typed positive to compare
// against for a suppressed report. [#2873]
!stripAnsi(g.stderr).includes(' shadowed: the '),
`expected the injected report failure to be swallowed silently: ${g.stderr}`,
);
});
test('C6: re-running the same scope twice produces the same report', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2873-c6-'));
const projectDir = path.join(root, 'myrepo');
fs.mkdirSync(projectDir, { recursive: true });
t.after(() => cleanup(root));
const g = spawnInstall(['--claude', '--global'], root, root);
assert.strictEqual(g.exitCode, 0, `global install failed: ${g.stdout}\n${g.stderr}`);
const l1 = spawnInstall(['--claude', '--local'], projectDir, root);
assert.strictEqual(l1.exitCode, 0, `first local install failed: ${l1.stdout}\n${l1.stderr}`);
const l2 = spawnInstall(['--claude', '--local'], projectDir, root);
assert.strictEqual(l2.exitCode, 0, `second local install failed: ${l2.stdout}\n${l2.stderr}`);
const expectedReport = buildShadowReport('claude', { home: root, cwd: projectDir });
assert.strictEqual(expectedReport.shadowed, true);
assertReportRendered(l1.stderr, expectedReport);
assertReportRendered(l2.stderr, expectedReport);
});
});

View File

@@ -328,3 +328,47 @@ describe('buildShadowReport — the lstatSync symlink guard (B14-B16)', () => {
assert.strictEqual(report.triggers.length, 1);
});
});
// ─── B17-B18 — zalgo / zero-width, #2873 PR review Finding 2 (MINOR) ──────
//
// `sanitizeForRender` stripped ANSI, C0/C1, and bidi overrides/isolates, but
// not combining marks (U+0300-U+036F — "zalgo" text, which visually
// overflows into adjacent terminal cells) or zero-width characters (ZWSP
// U+200B, ZWNJ U+200C, ZWJ U+200D, BOM/ZWNBSP U+FEFF). Neither class is a JS
// `\s`, so both survived the 64-char cap and the whitespace-collapse step
// undetected.
// Written as `\u{...}` escapes throughout (never literal combining/bidi/
// zero-width characters) so the source stays plain ASCII and does not
// visually combine with adjacent punctuation in editors/diffs.
const ZALGO_COMBINING_1 = '\u{0300}'; // combining grave accent
const ZALGO_COMBINING_2 = '\u{0301}'; // combining acute accent
const ZALGO_COMBINING_3 = '\u{036F}'; // combining latin small letter x (top of range)
const ZWSP = '\u{200B}';
const ZWNJ = '\u{200C}';
const ZWJ = '\u{200D}';
const BOM = '\u{FEFF}';
describe('buildShadowReport — hostile declaredRuntime is sanitized in the IR (B17-B18)', () => {
test('combining marks (zalgo) are stripped (B17)', () => {
const report = declaredRuntimeReport(`a${ZALGO_COMBINING_1}${ZALGO_COMBINING_2}${ZALGO_COMBINING_3}b`);
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'ab');
assert.ok(!/[\u{0300}-\u{036F}]/u.test(report.mismatches[0].declaredRuntime));
});
test('zero-width characters (ZWSP/ZWNJ/ZWJ/BOM) are stripped (B18)', () => {
const report = declaredRuntimeReport(`a${ZWSP}b${ZWNJ}c${ZWJ}d${BOM}e`);
assert.strictEqual(report.mismatches.length, 1);
assert.strictEqual(report.mismatches[0].declaredRuntime, 'abcde');
assert.ok(!/[\u{200B}-\u{200D}\u{FEFF}]/u.test(report.mismatches[0].declaredRuntime));
});
});
// Note: the F3 property ("sanitizer output contains no character in the
// stripped class, for arbitrary input") lives in `tests/shadow-report.test.cjs`
// alongside F2 (sanitizer idempotence) — both target `sanitizeForRender` and
// share one hostile-input generator, extended for #2873 PR review Finding 2
// (MINOR) to also emit combining marks (zalgo) and zero-width characters so
// the property actually exercises the newly-stripped classes rather than
// passing vacuously.

View File

@@ -41,6 +41,7 @@ const fc = require('./helpers/fast-check-setup.cjs');
const {
buildShadowReport,
renderShadowReport,
sanitizeForRender,
SHADOW_REASON,
} = require('../gsd-core/bin/lib/install-shadow-report.cjs');
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
@@ -561,6 +562,268 @@ describe('buildShadowReport — property (F1)', () => {
});
});
// ─── F2/F3 — sanitizeForRender properties (idempotence, stripped-class-free) ─
describe('sanitizeForRender — properties (F2, F3)', () => {
// Explicit seed + bounded numRuns (CONTRIBUTING: unseeded property tests
// are a review blocker), matching F1/F4's seed above.
const SEED = 20260814;
const NUM_RUNS = 300;
// Hostile-input generator: ANSI CSI/OSC escape sequences, C0 controls
// (including bare \x00 and a lone unterminated \x1b), DEL/C1, Unicode bidi
// embedding/override + isolate controls, combining marks ("zalgo",
// U+0300-U+036F), zero-width characters (ZWSP/ZWNJ/ZWJ/BOM), astral-plane
// characters (surrogate-pair-backed — real emoji/supplementary-plane text,
// not just printable ASCII), CRLF/LF/CR newlines, and plain text —
// interleaved so a single generated string usually mixes several hostile
// classes at once, per the brief's "not just printable ASCII, or the
// properties are vacuous" requirement.
//
// #2873 PR review Finding 2 (MINOR): `sanitizeForRender` originally
// stripped ANSI/control/bidi only, missing combining marks and zero-width
// characters — neither is a JS `\s`, so both survived the 64-char cap and
// the whitespace-collapse step undetected. `combiningArb`/`zeroWidthArb`
// and the extended `STRIPPED_CLASS_RE` below close that generator gap.
const c0ControlArb = fc.integer({ min: 0x00, max: 0x1f }).map((c) => String.fromCharCode(c));
const delC1Arb = fc.integer({ min: 0x7f, max: 0x9f }).map((c) => String.fromCharCode(c));
const ansiCsiArb = fc.constantFrom('\x1b[31m', '\x1b[0m', '\x1b[2K', '\x1b[1;37;40m');
const ansiOscArb = fc.constantFrom('\x1b]0;title\x07', '\x1b]8;;http://example\x1b\\');
const bidiArb = fc.constantFrom(
'‪', '‫', '‬', '‭', '‮', // embedding/override
'⁦', '⁧', '⁨', '⁩', // isolates
);
const combiningArb = fc.constantFrom('\u{0300}', '\u{0301}', '\u{0302}', '\u{036F}');
const zeroWidthArb = fc.constantFrom('\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}');
const astralArb = fc.constantFrom('\u{1F600}', '\u{1F4A9}', '\u{10000}', '\u{1F469}\u{200D}\u{1F4BB}');
const newlineArb = fc.constantFrom('\n', '\r\n', '\r');
const plainArb = fc.string({ maxLength: 12 });
const hostileChunkArb = fc.oneof(
c0ControlArb, delC1Arb, ansiCsiArb, ansiOscArb, bidiArb, combiningArb, zeroWidthArb, astralArb, newlineArb, plainArb,
);
const hostileStringArb = fc.array(hostileChunkArb, { maxLength: 10 }).map((parts) => parts.join(''));
// The stripped classes sanitizeForRender documents (ANSI escapes, C0
// controls + DEL/C1, Unicode bidi override/isolate, combining marks,
// zero-width characters), checked with an INDEPENDENT regex here rather
// than re-requiring the module's private ANSI_RE/CONTROL_RE/BIDI_RE/
// COMBINING_MARK_RE/ZERO_WIDTH_RE — so F3 is a real invariant check
// against the module's documented contract, not a tautology against its
// own internals.
// eslint-disable-next-line no-control-regex, no-misleading-character-class
const STRIPPED_CLASS_RE = /[\x00-\x1f\x7f-\x9f‪-‮⁦-⁩\u{0300}-\u{036F}\u{200B}-\u{200D}\u{FEFF}]/u;
test('sanitizer is idempotent: s(s(x)) === s(x) for arbitrary strings (F2)', () => {
let changedCount = 0;
fc.assert(
fc.property(hostileStringArb, (input) => {
const once = sanitizeForRender(input);
if (once !== input) changedCount += 1;
const twice = sanitizeForRender(once);
assert.strictEqual(
twice,
once,
`not idempotent — input: ${JSON.stringify(input)}\nonce: ${JSON.stringify(once)}\ntwice: ${JSON.stringify(twice)}`,
);
}),
{ seed: SEED, numRuns: NUM_RUNS },
);
// Non-vacuity (mirrors F4's real-shape-generator rationale above): prove
// the generator actually produced input sanitizeForRender changed at
// least once, or this property would pass trivially over inert strings.
assert.ok(changedCount > 0, 'generator never produced a string sanitizeForRender actually changed — F2 would be vacuous');
});
test('sanitizer output never contains a stripped-class character, for arbitrary input (F3)', () => {
let hostileInputCount = 0;
fc.assert(
fc.property(hostileStringArb, (input) => {
if (STRIPPED_CLASS_RE.test(input)) hostileInputCount += 1;
const output = sanitizeForRender(input);
assert.ok(
output === null || !STRIPPED_CLASS_RE.test(output),
`stripped-class character survived sanitization — input: ${JSON.stringify(input)}\noutput: ${JSON.stringify(output)}`,
);
}),
{ seed: SEED, numRuns: NUM_RUNS },
);
// Non-vacuity: prove the generator actually exercised at least one
// stripped-class character, or F3 would hold trivially over clean input.
assert.ok(hostileInputCount > 0, 'generator never produced a stripped-class character — F3 would be vacuous');
});
});
// ─── E1-E12 — resolveSpecRootReference direct unit coverage ───────────────
// Matrix section E (`.gsd/phase/feat-2873-cross-scope-shadowing/50-test-matrix.md`).
// Only the E13/E14 installed-output integration pair (in
// tests/install-runtime-artifacts.test.cjs) and F4's idempotence property
// (above) touched this exported function before this block — these rows
// exercise it DIRECTLY, one behavior at a time.
describe('resolveSpecRootReference — direct unit coverage (E1-E12)', () => {
test('global skill body: the include is replaced by the two-step imperative form naming both candidates (E1)', () => {
const body = '@~/.claude/gsd-core/workflows/plan-phase.md';
const result = resolveSpecRootReference(body);
assert.notStrictEqual(result, body);
assert.ok(!result.startsWith('@'), 'the static @-include must be gone');
assert.ok(
result.includes('.claude/gsd-core/workflows/plan-phase.md') && result.includes('~/.claude/gsd-core/workflows/plan-phase.md'),
`expected both the project-local and global candidate paths named in: ${JSON.stringify(result)}`,
);
});
test('local command body: byte-identical to today (E2)', () => {
// The REAL literal a local claude install emits for this same source
// line (verified empirically against a real --local install): the
// installer's path-prefix rewrite resolves the local scope's absolute
// config dir, never `~`, so this never has the `@~/.claude/` shape
// WORKFLOW_SPEC_ROOT_INCLUDE_RE requires in the first place — a genuine
// non-qualifying condition, not a hand-waved one.
const body = '@/Users/dev/myrepo/.claude/gsd-core/workflows/plan-phase.md';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('every non-claude runtime, both scopes: byte-identical to today (E3)', () => {
// Real literal shapes emitted for other runtimes (verified empirically
// against a real --cursor --global install): no `.claude/` segment at
// all, so none of them ever match the claude-only spec-root regex.
const cursorGlobal = '@$HOME/gsd-core/workflows/plan-phase.md';
const genericLocal = '@./gsd-core/workflows/plan-phase.md';
assert.strictEqual(resolveSpecRootReference(cursorGlobal), cursorGlobal);
assert.strictEqual(resolveSpecRootReference(genericLocal), genericLocal);
});
test('a references/ include is a different spec root and stays static (E4)', () => {
const body = '@~/.claude/gsd-core/references/ui-brand.md';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('an @.planning/… include is untouched (E5)', () => {
const body = '@.planning/notes.md';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('an include inside a fenced code block is untouched, byte-identical (E6)', () => {
const body = ['```', '@~/.claude/gsd-core/workflows/plan-phase.md', '```'].join('\n');
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('an include mentioned in inline backticks is untouched, byte-identical (E7)', () => {
const body = 'See `@~/.claude/gsd-core/workflows/plan-phase.md` for the spec.';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('a body with no workflow include is a no-op (E8)', () => {
const body = 'Just some ordinary command prose with no includes at all.';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('two independent workflow includes both resolve (E9)', () => {
const body = [
'@~/.claude/gsd-core/workflows/plan-phase.md',
'@~/.claude/gsd-core/workflows/execute-phase.md',
].join('\n');
const result = resolveSpecRootReference(body);
assert.ok(!result.includes('@~/.claude/gsd-core/workflows/plan-phase.md'));
assert.ok(!result.includes('@~/.claude/gsd-core/workflows/execute-phase.md'));
assert.ok(result.includes('.claude/gsd-core/workflows/plan-phase.md'));
assert.ok(result.includes('.claude/gsd-core/workflows/execute-phase.md'));
});
test('prose merely mentioning gsd-core/workflows/x.md is untouched (E10)', () => {
const body = 'See gsd-core/workflows/plan-phase.md for background on how this works.';
assert.strictEqual(resolveSpecRootReference(body), body);
});
test('a CRLF body emits identically to LF, no orphaned \\r (E11)', () => {
const bodyLf = '@~/.claude/gsd-core/workflows/plan-phase.md\nSecond line.';
const bodyCrlf = '@~/.claude/gsd-core/workflows/plan-phase.md\r\nSecond line.';
const resultLf = resolveSpecRootReference(bodyLf);
const resultCrlf = resolveSpecRootReference(bodyCrlf);
assert.strictEqual(resultCrlf, resultLf.replace('\n', '\r\n'));
// Every `\r` in the result must be immediately followed by `\n` — an
// orphaned CR (one not paired with the LF that owns it) would mean the
// transform dropped or duplicated a line-ending byte.
assert.ok(!/\r(?!\n)/.test(resultCrlf), `orphaned CR found in: ${JSON.stringify(resultCrlf)}`);
});
test('applying the transform twice over an installed tree is idempotent (E12)', () => {
const body = 'intro\n@~/.claude/gsd-core/workflows/plan-phase.md\noutro';
const once = resolveSpecRootReference(body);
const twice = resolveSpecRootReference(once);
assert.strictEqual(twice, once);
});
});
// ─── E-rows — resolveSpecRootReference fence-detection regressions ────────
// (#2873 PR review Finding 1, MEDIUM): the hand-rolled `FENCE_DELIMITER_RE`
// tracker toggled open/closed on ANY delimiter line regardless of type,
// which is wrong under CommonMark (a closer must share the opener's
// delimiter character and have run length >= the opener's). The fix reuses
// `scanFencedBlocks` (`markdown-sectionizer.cts`). These cases pin the exact
// failure the review constructed plus the sibling CommonMark edge cases
// named in the review (nested fences, an unterminated fence, and a
// longer-run opener closed by a too-short run).
describe('resolveSpecRootReference — unit (fence detection, #2873 review Finding 1)', () => {
test('mismatched fence types (``` opened, ~~~ inside, ``` closes) leave BOTH includes untouched', () => {
const body = [
'```',
'@~/.claude/gsd-core/workflows/alpha.md',
'~~~',
'@~/.claude/gsd-core/workflows/beta.md',
'```',
].join('\n');
const result = resolveSpecRootReference(body);
assert.strictEqual(result, body, 'a ``` fence is not closed by a ~~~ line — both includes must stay inside the one open block');
assert.ok(!result.includes('To load this command'), 'no rewrite marker should appear when both includes are fenced');
});
test('nested fences (outer run longer than an inner same-char run) leave the enclosed include untouched', () => {
const body = [
'````',
'```',
'@~/.claude/gsd-core/workflows/nested.md',
'```',
'````',
].join('\n');
const result = resolveSpecRootReference(body);
assert.strictEqual(result, body, 'the inner 3-backtick lines are content, not closers, for a 4-backtick opener');
});
test('an unterminated fence covers to end-of-string, leaving the include untouched', () => {
const body = [
'```',
'@~/.claude/gsd-core/workflows/orphan.md',
].join('\n');
const result = resolveSpecRootReference(body);
assert.strictEqual(result, body, 'a fence with no closer is still open through EOF');
});
test('a fence opened with a longer run (````) is NOT closed by a shorter run (```)', () => {
const body = [
'````',
'@~/.claude/gsd-core/workflows/longshort.md',
'```',
'@~/.claude/gsd-core/workflows/other.md',
'````',
].join('\n');
const result = resolveSpecRootReference(body);
assert.strictEqual(
result,
body,
'a 3-backtick line cannot close a 4-backtick opener per CommonMark run-length rule — both includes stay inside the one fence',
);
});
});
// ─── F4 — resolveSpecRootReference is idempotent over arbitrary bodies ────
describe('resolveSpecRootReference — property (F4)', () => {
@@ -583,6 +846,27 @@ describe('resolveSpecRootReference — property (F4)', () => {
const fencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map(
([fence, s]) => `${fence}\n@~/.claude/gsd-core/workflows/${s}.md\n${fence}`,
);
// #2873 review Finding 1: a same-type-only generator is structurally
// incapable of producing the mismatched-delimiter defect the review
// constructed (a ``` fence "closed" by a ~~~ line). Also emit
// mismatched-type and nested-run shapes so the property actually
// exercises the CommonMark same-type/same-or-longer-run closing rule,
// not just the trivial same-fence-twice case.
const mismatchedFencedIncludeArb = fc.tuple(
fc.constantFrom(['```', '~~~'], ['~~~', '```']),
stemArb,
stemArb,
).map(
([[openFence, midFence], s1, s2]) =>
`${openFence}\n@~/.claude/gsd-core/workflows/${s1}.md\n${midFence}\n@~/.claude/gsd-core/workflows/${s2}.md\n${openFence}`,
);
const nestedFencedIncludeArb = fc.tuple(fc.constantFrom('```', '~~~'), stemArb).map(
([fenceChar, s]) => {
const inner = fenceChar.repeat(3);
const outer = fenceChar.repeat(4);
return `${outer}\n${inner}\n@~/.claude/gsd-core/workflows/${s}.md\n${inner}\n${outer}`;
},
);
const plainTextArb = fc.string({ maxLength: 40 });
const chunkArb = fc.oneof(
@@ -590,6 +874,8 @@ describe('resolveSpecRootReference — property (F4)', () => {
proseMentionArb,
planningIncludeArb,
fencedIncludeArb,
mismatchedFencedIncludeArb,
nestedFencedIncludeArb,
plainTextArb,
);
const bodyArb = fc.array(chunkArb, { maxLength: 12 }).map((chunks) => chunks.join('\n'));