* feat(#2930): fragmentize plan-phase.md workflow into per-runtime-composed sections Adds src/workflow-fragments.cts (in-file <!-- gsd:section --> marker parser/composer, ADR-1671 epic #1671 Phase 3), wires it into bin/install.js's copyWithPathReplacement emission path, and pilots the marker grammar on gsd-core/workflows/plan-phase.md. Bookkeeping ripple for the new src/*.cts module: .gitignore, eslint.config.mjs, docs/INVENTORY.md + docs/INVENTORY-MANIFEST.json, and a CONTEXT.md glossary entry. Amends ADR-1671 with open questions 1 and 2 resolutions and records the closed when= applicability grammar. Adds docs/reference/workflow-fragments.md and an ARCHITECTURE.md section documenting the marker authoring model. * fix(#2930): put allow-test-rule issue ref on the same line as the marker lint-allow-test-rule-refs.cjs requires the #NNN issue reference on the same source line as `allow-test-rule:`; it was one line below and read as an unreferenced novel exemption. * docs(#2930): link the orphaned gate-predicates reference from the docs index Found while adding the workflow-fragments reference doc: docs/reference/gate-predicates.md shipped without an entry in docs/README.md, so it was unreachable from the docs index. Fixed inline rather than deferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2930): scope composition to workflows, add typed failure reasons Review findings from two orthogonal passes: - Scope composeWorkflow to gsd-core/workflows/ only. It previously ran on every .md the installer copied, so a future agent/command/reference doc documenting the marker syntax with an unfenced example would have been mis-parsed and silently stripped — a lossy drop the phase forbids. - Add a frozen REASON enum; failures attach a typed .reason and tests assert on it instead of matching free-form message text (CONTRIBUTING.md:635-694). - Derive the property generator's when= values from WHEN_VOCABULARY instead of duplicating them (DEFECT.GENERATIVE-FIX). - Add adversarial parser fixtures: Unicode headings, NUL, U+FFFD, BOM, fence-within-fence, tilde and indented fences, lone-CR marker line. - Document why --mvp is structurally unmarkable: its content is interleaved, not sectioned, so the whole-line grammar cannot reach it. Also fixes two stale tests on this branch, each reproduced on the unmodified tree before correction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2930): retarget the pilot from plan-phase to execute-phase The full remote matrix went red on both Linux lanes. Root cause was ours: tests/phase6-capstone-conformance.test.cjs holds a PRE_PHASE6 ceiling of 94519 bytes for plan-phase.md, asserting an ADR-857 Phase-6 completion property. That is a third size gate beyond the tier caps and the differential ratchet, and it left plan-phase.md just 36 bytes of headroom rather than the 3821 computed from the XL cap. The 330 marker bytes overran it by 294. Raising the ceiling is not an option: it is a red line certifying another ADR's completion. plan-phase.md is reverted to byte-identical origin/next and the pilot moves to execute-phase.md, which has 728 bytes of headroom under its own ceiling and lands at 93147 with 3 marker pairs. The vocabulary narrows to the atoms actually used: always, flag:--wave, state:gap-closure-phase, state:has-prior-phases. Recorded in the ADR: every branch the epic names lives in plan-phase.md, which cannot be fragmentized until caps move from source to emitted bytes. That is direct evidence for the epic's premise and may reorder phases 3-4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#2930): backfill changeset PR number (#2972) * fix(#2930): make the emission install tests portable on Windows The windows-latest lane went red on three tests in the new install suite; Linux was green. Both causes were in the test harness, not the module. Root normalization: the opencode converter always embeds the install root forward-slashed, but the tests stripped it with the native-separator string from mkdtemp. On Windows that never matched, so the root leaked through unstripped — and because the real and stub install roots have different prefix lengths, that length difference landed directly in the byte-delta assertion (344 observed vs 275 expected). Normalize both text and root to one separator form before stripping. @-ref resolution: the helper stripped only the @~/ and @$HOME/ forms, so a Windows absolute ref (@C:/Users/...) fell through and was joined onto the root, producing ...\@C:\Users\... Strip the @ first, then detect absoluteness from the token's own shape (POSIX, drive-letter, or UNC) with no platform branching, so every OS takes the same path. Neither assertion was weakened; the exact-equality byte check is the point of the test and still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(#2930): document every REASON member and guard the doc/enum parity Code review found the reference doc's 'Fails closed' list covering 10 of the 11 frozen REASON members — MALFORMED_ATTRIBUTES (parseAttrs rejects malformed key="value" syntax) had no bullet, and it is distinct from UNRECOGNIZED_ATTRIBUTE, which is valid syntax with an unknown key. Two parallel surfaces sharing one constant with nothing asserting they agree is the DEFECT.GENERATIVE-FIX class, so the same commit adds the parity assertion: the test derives the enum side from the built module and the doc side by parsing the reference page, keyed on the reason IDENTIFIER rather than prose so a reworded bullet does not break it, and reports set differences in both directions by name. Proven non-vacuous: removing the MALFORMED_ATTRIBUTES bullet turns the suite red naming that exact member; restoring it returns 44/44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"execute-phase.md": "#2930 (epic #1671 Phase 3): pilots the in-file `<!-- gsd:section -->` marker grammar by wrapping the --wave/gap-closure/regression-gate branch sections (partial-wave, gap-closure-artifacts, regression-gate) in marker pairs, proving the composeWorkflow seam runs at install time before per-runtime rewrites. Retargeted from plan-phase.md (chore/2930 review): plan-phase.md sits only 36 B under the ADR-857 Phase-6 PRE_PHASE6 gate (tests/phase6-capstone-conformance.test.cjs) and cannot absorb marker overhead, so the maintainer retargeted the pilot to execute-phase.md, which has 728 B of headroom under its own PRE_PHASE6 cap. SOURCE grows by exactly 275 marker bytes (6 marker lines); the EMITTED artifact composeWorkflow produces at install is byte-identical to the pre-#2930 file (markers are stripped, never shipped). See .gsd/phase/chore-2930-fragmentize-xl-workflow/40-design.md 'Known limits' item 5."
|
||||
}
|
||||
}
|
||||
484
tests/workflow-fragments-emission.install.test.cjs
Normal file
484
tests/workflow-fragments-emission.install.test.cjs
Normal file
@@ -0,0 +1,484 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product — noSectionMarkerLeaksIntoEmittedArtifacts (#2930)
|
||||
// asserts on the literal bytes of an EMITTED install artifact, which IS the
|
||||
// deployed contract (a leaked `gsd:section` marker byte would ship to every user). This
|
||||
// mirrors the test-matrix's own row-34/35 exemption from the "no source-grep" rule
|
||||
// (50-test-matrix.md "No source-grep" note) — the ESLint rule itself only fires on
|
||||
// readFileSync of a .cjs/.js/.ts SOURCE path, never on an installed .md artifact, so this
|
||||
// annotation is documentation of intent, not a required suppression.
|
||||
|
||||
/**
|
||||
* workflow-fragments-emission.install.test.cjs — 50-test-matrix.md rows 32-36
|
||||
* (issue #2930, epic #1671 Phase 3).
|
||||
*
|
||||
* Real spawn-install coverage for `composeWorkflow`'s wiring into
|
||||
* `bin/install.js`'s `copyWithPathReplacement` (ADR-1671 "Architecture and
|
||||
* contracts": an engine-direct assertion is false-green for install
|
||||
* behavior — only a real spawned installer proves bytes actually reach
|
||||
* disk). The pure parser/composer itself is covered by
|
||||
* tests/workflow-fragments.test.cjs (unit, rows 1-29/37) and
|
||||
* tests/workflow-fragments.property.test.cjs (prop, rows 30-31).
|
||||
*
|
||||
* Each test builds and tears down its own tmp fixture(s) inline (no shared
|
||||
* `before()` install cache) — independence per matrix row 38.
|
||||
*
|
||||
* ── The overlay technique (rows 33/36) ───────────────────────────────────
|
||||
*
|
||||
* Rows 33 and 36 need a spawned `bin/install.js` that reads a DIFFERENT
|
||||
* `gsd-core/workflows/execute-phase.md` (malformed, row 36) or a different
|
||||
* `gsd-core/bin/lib/workflow-fragments.cjs` (stubbed to identity, row 33)
|
||||
* than this checkout's real files, without paying to copy the ~400 MB
|
||||
* repository (mostly node_modules) for every run. `buildOverlayRepo` mirrors
|
||||
* the repo tree with real directories (so `copyWithPathReplacement`'s own
|
||||
* `entry.isDirectory()` / `entry.isFile()` Dirent checks — which do NOT
|
||||
* follow symlinks — see the correct type) and HARD-LINKS every unmodified
|
||||
* leaf file (not symlinks: a symlinked leaf file also fails an `isFile()`
|
||||
* Dirent check elsewhere in the installer, verified empirically — "Failed
|
||||
* to install agents: directory is empty" against a symlink-leaf overlay).
|
||||
* Only `node_modules` and `.git` are symlinked at the top level (install.js
|
||||
* never walks into either), which is what keeps the overlay build fast.
|
||||
* Every overlay-spawned installer runs with `--preserve-symlinks
|
||||
* --preserve-symlinks-main` as a defensive belt: with an all-hardlink leaf
|
||||
* layout this checkout does not currently NEED symlink-preservation for
|
||||
* correctness, but the flag is free insurance against a future install.js
|
||||
* change that resolves a node_modules package by real path.
|
||||
*/
|
||||
|
||||
const { test } = 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 crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { RUNTIME_META, runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs');
|
||||
const { executionContextRefs } = require('../scripts/command-contract-helpers.cjs');
|
||||
const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md');
|
||||
const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL);
|
||||
// plan-phase.md was the original #2930 pilot but was reverted to unmarked
|
||||
// (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6
|
||||
// gate and cannot absorb marker overhead) — it is now a genuinely unmarked
|
||||
// file again, so row 33 uses it instead of plan-phase.md.
|
||||
const UNMARKED_REL = path.join('gsd-core', 'workflows', 'plan-phase.md');
|
||||
|
||||
const RUNTIMES = Object.keys(RUNTIME_META);
|
||||
|
||||
// ─── Overlay-repo builder (rows 33/36) ─────────────────────────────────────
|
||||
|
||||
const OVERLAY_SKIP_TOP = new Set(['node_modules', '.git']);
|
||||
|
||||
/** Hard-link a file, falling back to a real copy only if the two paths sit on
|
||||
* different filesystems/devices (EXDEV) or linking is denied (EPERM) — both
|
||||
* cross-platform-legitimate, unlike a symlink's Dirent type-detection gap. */
|
||||
function linkOrCopyFile(src, dest) {
|
||||
try {
|
||||
fs.linkSync(src, dest);
|
||||
} catch (err) {
|
||||
if (err.code === 'EXDEV' || err.code === 'EPERM') {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a throwaway mirror of REPO_ROOT with real directories throughout and
|
||||
* every unmodified leaf file hard-linked, except the paths named in
|
||||
* `fileOverrides` (POSIX-relative-path -> content string), which are written
|
||||
* as real files. Returns the mirror's absolute path; caller must
|
||||
* `fs.rmSync(..., {recursive:true, force:true})` it away.
|
||||
*
|
||||
* @param {{[relPath: string]: string}} fileOverrides
|
||||
*/
|
||||
function buildOverlayRepo(fileOverrides) {
|
||||
const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2930-overlay-'));
|
||||
const entries = Object.entries(fileOverrides).map(([relPath, content]) => ({
|
||||
parts: relPath.split('/'),
|
||||
content,
|
||||
}));
|
||||
|
||||
function place(srcDir, destDir, pending, isTop) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const grouped = new Map();
|
||||
for (const e of pending) {
|
||||
const [head, ...rest] = e.parts;
|
||||
if (!grouped.has(head)) grouped.set(head, []);
|
||||
grouped.get(head).push({ parts: rest, content: e.content });
|
||||
}
|
||||
for (const de of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (isTop && OVERLAY_SKIP_TOP.has(de.name)) {
|
||||
fs.symlinkSync(path.join(srcDir, de.name), path.join(destDir, de.name));
|
||||
continue;
|
||||
}
|
||||
const srcPath = path.join(srcDir, de.name);
|
||||
const destPath = path.join(destDir, de.name);
|
||||
const overridden = grouped.get(de.name);
|
||||
const leaf = overridden && overridden.find((s) => s.parts.length === 0);
|
||||
if (leaf) {
|
||||
fs.writeFileSync(destPath, leaf.content);
|
||||
continue;
|
||||
}
|
||||
// fs.statSync follows symlinks (unlike Dirent.isDirectory()), so a
|
||||
// symlinked source directory is still recursed as a REAL directory in
|
||||
// the overlay — the property copyWithPathReplacement itself needs.
|
||||
if (fs.statSync(srcPath).isDirectory()) {
|
||||
place(srcPath, destPath, overridden || [], false);
|
||||
} else {
|
||||
linkOrCopyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
place(REPO_ROOT, tmpRepo, entries, true);
|
||||
return tmpRepo;
|
||||
}
|
||||
|
||||
/** Spawn a (possibly overlaid) installScript at global scope. Does NOT
|
||||
* assert success — callers decide (row 36 expects failure). */
|
||||
function spawnGlobalInstall(installScript, runtime, extraArgs = []) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2930-dest-${runtime}-`));
|
||||
const args = [
|
||||
'--preserve-symlinks',
|
||||
'--preserve-symlinks-main',
|
||||
installScript,
|
||||
`--${runtime}`,
|
||||
'--global',
|
||||
'--config-dir',
|
||||
root,
|
||||
...extraArgs,
|
||||
];
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
||||
});
|
||||
return { result, configDir: root, root };
|
||||
}
|
||||
|
||||
/** Convert native path separators to POSIX forward slashes, unconditionally
|
||||
* (never gate on `path.sep` — CONTEXT.md's path-separator-normalization
|
||||
* rule). Windows installs embed the SAME root in more than one spelling:
|
||||
* the `@`-ref / pathPrefix rewrites always emit posix-normalized
|
||||
* forward-slash paths, while other embedded content can still carry the
|
||||
* native backslash spelling. `root` itself (from `fs.mkdtempSync`) is a
|
||||
* native-separator string, so comparing it against text verbatim only
|
||||
* matches ONE of those spellings. */
|
||||
function toPosixSlashes(value) {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/** Strip an install's own absolute root out of emitted text so two installs
|
||||
* under DIFFERENT temp roots (different lengths, different runtime-name
|
||||
* prefixes) can be compared byte-for-byte. Normalizes BOTH the text and the
|
||||
* root to forward-slash spelling first, so every embedded spelling of the
|
||||
* root collapses onto the SAME placeholder — leaving the comparison
|
||||
* measuring only composeWorkflow's own contribution. */
|
||||
function stripRoot(text, root) {
|
||||
return toPosixSlashes(text).split(toPosixSlashes(root)).join('<ROOT>');
|
||||
}
|
||||
|
||||
// ─── Row 32: emitted execute-phase.md shrinks by exactly the marker bytes ────
|
||||
//
|
||||
// Defect found and fixed inline while verifying (chore/2930 review; not one
|
||||
// of the five assigned findings, but discovered incidentally): this test
|
||||
// previously compared the RAW `composeWorkflow(source)` byte length directly
|
||||
// against `fs.statSync(emittedPath).size`. That equality only holds when the
|
||||
// OTHER rewrites `copyWithPathReplacement` also runs (the `~/.claude/` ->
|
||||
// `pathPrefix` substitution, attribution stamping, per-runtime converters)
|
||||
// happen to be byte-neutral — which they are NOT here: `runMinimalInstall`
|
||||
// passes `--config-dir root` with `HOME=root` (root IS the target, not
|
||||
// `root/.claude`), so `computePathPrefix` degenerates to the short literal
|
||||
// `$HOME/` instead of the real-world `$HOME/.claude/`, shrinking the
|
||||
// installed `~/.claude/` references by additional bytes unrelated to
|
||||
// composeWorkflow. Proven with a real spawned install and reverted to
|
||||
// confirm this reproduces on the UNMODIFIED tree, before this change. Fixed
|
||||
// by isolating composeWorkflow's OWN contribution the same way row 33
|
||||
// already does: compare two REAL installs of the pilot workflow, one via
|
||||
// this checkout's real composeWorkflow and one via an identity-stubbed
|
||||
// composeWorkflow, and assert the size DELTA equals exactly the marker
|
||||
// bytes stripped — never an absolute emitted byte count, which conflates
|
||||
// unrelated rewrites this module does not own.
|
||||
//
|
||||
// A second, independent contaminant surfaced fixing the first one: opencode
|
||||
// embeds the install's own absolute configDir path into execute-phase.md
|
||||
// content (same fact row 33/atRefContractStillResolvesAfterComposition
|
||||
// documents for SKILL.md), and `runMinimalInstall`'s temp-dir prefix
|
||||
// (`gsd-<runtime>-<scope>-`) is a DIFFERENT length than
|
||||
// `spawnGlobalInstall`'s (`gsd-2930-dest-<runtime>-`) — so comparing RAW
|
||||
// file sizes between the two installs bakes in a root-path-length delta
|
||||
// that has nothing to do with composeWorkflow. Normalize each side's own
|
||||
// root out of the text before measuring, exactly as row 33 already does.
|
||||
|
||||
test('emittedWorkflowShrinksByMarkerBytesForEveryRuntime', () => {
|
||||
const source = fs.readFileSync(PILOT_PATH, 'utf8');
|
||||
const composed = composeWorkflow(source, { sourcePath: PILOT_PATH });
|
||||
const sourceBytes = Buffer.byteLength(source, 'utf8');
|
||||
const composedBytes = Buffer.byteLength(composed, 'utf8');
|
||||
const expectedMarkerBytes = sourceBytes - composedBytes;
|
||||
assert.ok(
|
||||
expectedMarkerBytes > 0,
|
||||
'sanity: the pilot workflow must actually carry gsd:section markers to strip',
|
||||
);
|
||||
|
||||
const identityStubRepo = buildOverlayRepo({
|
||||
'gsd-core/bin/lib/workflow-fragments.cjs': 'module.exports = { composeWorkflow: (c) => c };\n',
|
||||
});
|
||||
try {
|
||||
for (const runtime of RUNTIMES) {
|
||||
const real = runMinimalInstall({ runtime, scope: 'global' });
|
||||
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
||||
try {
|
||||
assert.equal(
|
||||
stub.result.status,
|
||||
0,
|
||||
`${runtime}: identity-stub install must succeed\nstderr: ${stub.result.stderr}`,
|
||||
);
|
||||
const realPath = path.join(real.configDir, PILOT_REL);
|
||||
const stubPath = path.join(stub.configDir, PILOT_REL);
|
||||
assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing execute-phase.md`);
|
||||
assert.ok(fs.existsSync(stubPath), `${runtime}: identity-stub install is missing execute-phase.md`);
|
||||
const realText = stripRoot(fs.readFileSync(realPath, 'utf8'), real.root);
|
||||
const stubText = stripRoot(fs.readFileSync(stubPath, 'utf8'), stub.root);
|
||||
const realBytes = Buffer.byteLength(realText, 'utf8');
|
||||
const stubBytes = Buffer.byteLength(stubText, 'utf8');
|
||||
assert.equal(
|
||||
stubBytes - realBytes,
|
||||
expectedMarkerBytes,
|
||||
`${runtime}: emitted size delta (stub ${stubBytes} - real ${realBytes}, root-normalized) must equal exactly the marker bytes stripped (${expectedMarkerBytes})`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(real.root);
|
||||
cleanup(stub.root);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(identityStubRepo);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Row 33: an unmarked workflow emits byte-identical for every runtime ──
|
||||
//
|
||||
// "Byte-identical" here means identical to what the SAME runtime's install
|
||||
// pipeline would emit WITHOUT the #2930 composeWorkflow wiring — not
|
||||
// necessarily identical to the raw repo source, since path-prefix rewrites,
|
||||
// attribution stamping, and per-runtime .md converters already ran before
|
||||
// this change and still run today. Proven empirically per runtime by
|
||||
// comparing two REAL installs of the SAME unmarked file: one through this
|
||||
// checkout's real composeWorkflow, one through an overlay whose
|
||||
// gsd-core/bin/lib/workflow-fragments.cjs is stubbed to plain identity — any
|
||||
// difference is attributable ONLY to the compose wiring, never to an
|
||||
// unrelated converter (which fires identically on both sides).
|
||||
|
||||
test('unmarkedWorkflowEmitsByteIdenticalForEveryRuntime', () => {
|
||||
const identityStubRepo = buildOverlayRepo({
|
||||
'gsd-core/bin/lib/workflow-fragments.cjs': 'module.exports = { composeWorkflow: (c) => c };\n',
|
||||
});
|
||||
try {
|
||||
for (const runtime of RUNTIMES) {
|
||||
const real = runMinimalInstall({ runtime, scope: 'global' });
|
||||
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
||||
try {
|
||||
assert.equal(
|
||||
stub.result.status,
|
||||
0,
|
||||
`${runtime}: identity-stub install must succeed\nstderr: ${stub.result.stderr}`,
|
||||
);
|
||||
const realPath = path.join(real.configDir, UNMARKED_REL);
|
||||
const stubPath = path.join(stub.configDir, UNMARKED_REL);
|
||||
assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing plan-phase.md`);
|
||||
assert.ok(fs.existsSync(stubPath), `${runtime}: stub install is missing plan-phase.md`);
|
||||
|
||||
// Normalize each side's own randomly-generated temp root out of the
|
||||
// content before hashing: some runtimes (opencode) embed the
|
||||
// install's own absolute configDir path in execution_context refs,
|
||||
// and the two installs necessarily used DIFFERENT temp roots — an
|
||||
// unnormalized compare would report a spurious mismatch driven by
|
||||
// temp-path length, not by anything composeWorkflow's wiring did.
|
||||
const realText = stripRoot(fs.readFileSync(realPath, 'utf8'), real.root);
|
||||
const stubText = stripRoot(fs.readFileSync(stubPath, 'utf8'), stub.root);
|
||||
assert.equal(
|
||||
Buffer.byteLength(realText, 'utf8'),
|
||||
Buffer.byteLength(stubText, 'utf8'),
|
||||
`${runtime}: plan-phase.md byte size drifted between real compose and identity-stub compose`,
|
||||
);
|
||||
const realHash = crypto.createHash('sha256').update(realText).digest('hex');
|
||||
const stubHash = crypto.createHash('sha256').update(stubText).digest('hex');
|
||||
assert.equal(
|
||||
realHash,
|
||||
stubHash,
|
||||
`${runtime}: plan-phase.md content drifted between real compose and identity-stub compose`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(real.root);
|
||||
cleanup(stub.root);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(identityStubRepo);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Row 34: no gsd:section marker survives into any emitted artifact ─────
|
||||
|
||||
test('noSectionMarkerLeaksIntoEmittedArtifacts', () => {
|
||||
for (const runtime of RUNTIMES) {
|
||||
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
||||
try {
|
||||
const emittedPath = path.join(configDir, PILOT_REL);
|
||||
assert.ok(fs.existsSync(emittedPath), `${runtime}: emitted execute-phase.md is missing`);
|
||||
const emittedText = fs.readFileSync(emittedPath, 'utf8');
|
||||
assert.equal(
|
||||
emittedText.includes('gsd:section'),
|
||||
false,
|
||||
`${runtime}: emitted execute-phase.md still contains a gsd:section marker token`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Row 35: ADR-0002 @-ref contract still resolves after composition ─────
|
||||
//
|
||||
// Two representative runtimes chosen to cover BOTH observed @-ref forms
|
||||
// (empirically confirmed, #2930 dispatch): claude/cursor/codex rewrite to
|
||||
// `@$HOME/...`, while opencode rewrites to a bare `@<absolute-path>/...`.
|
||||
// Both installed SKILL.md files themselves pass through composeWorkflow too
|
||||
// (as a no-op, being unmarked) — this proves that pass never corrupts or
|
||||
// relocates the referenced workflow file.
|
||||
|
||||
/** Detect absoluteness from the token's own shape only — never from
|
||||
* `process.platform` — so the same logic runs identically on every OS.
|
||||
* Covers POSIX (`/...`), Windows drive-letter (`C:/...` or `C:\...`), and
|
||||
* UNC (`\\server\share`) forms. */
|
||||
function isAbsoluteRefTarget(candidate) {
|
||||
return (
|
||||
candidate.startsWith('/') ||
|
||||
/^[a-zA-Z]:[\\/]/.test(candidate) ||
|
||||
candidate.startsWith('\\\\')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExecutionContextRefTarget(token, root) {
|
||||
const withoutAt = token.replace(/^@/, '');
|
||||
if (isAbsoluteRefTarget(withoutAt)) return withoutAt; // already absolute (opencode form)
|
||||
const stripped = withoutAt.replace(/^(?:~|\$HOME)\//, '');
|
||||
return path.join(root, stripped);
|
||||
}
|
||||
|
||||
test('atRefContractStillResolvesAfterComposition', () => {
|
||||
for (const runtime of ['claude', 'opencode']) {
|
||||
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
||||
try {
|
||||
const skillPath = path.join(configDir, 'skills', 'gsd-plan-phase', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), `${runtime}: installed gsd-plan-phase SKILL.md is missing`);
|
||||
const skillContent = fs.readFileSync(skillPath, 'utf8');
|
||||
const refs = executionContextRefs(skillContent);
|
||||
assert.ok(refs.length > 0, `${runtime}: SKILL.md has no execution_context @-refs to check`);
|
||||
for (const { token } of refs) {
|
||||
const target = resolveExecutionContextRefTarget(token, root);
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
`${runtime}: execution_context @-ref "${token}" resolved to "${target}", which does not exist on disk`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── FIX 1 (chore/2930 review): composeWorkflow is scoped to gsd-core/workflows/ ──
|
||||
//
|
||||
// copyWithPathReplacement is the emit path for the ENTIRE gsd-core/ tree
|
||||
// (skillSrc = path.join(src, 'gsd-core'), bin/install.js:10806-10809), not
|
||||
// just gsd-core/workflows/. A non-workflow .md elsewhere under gsd-core/
|
||||
// (e.g. gsd-core/references/) that merely DOCUMENTS the marker syntax with
|
||||
// an unfenced, structurally-invalid example line must never be run through
|
||||
// composeWorkflow — doing so would either throw (breaking install for an
|
||||
// unrelated file class) or silently strip/mis-parse the documentation line.
|
||||
// Proven here by overlaying BOTH a marked workflow (must still compose) and
|
||||
// an EXISTING non-workflow reference doc (buildOverlayRepo can only replace
|
||||
// the content of a real leaf file, not graft in a net-new path — see the
|
||||
// module doc comment's overlay-technique note) rewritten to carry an
|
||||
// intentionally-UNCLOSED marker-shaped line (would throw if composeWorkflow
|
||||
// ever touched it) in the SAME install run.
|
||||
|
||||
test('nonWorkflowMarkdownWithMarkerShapedLineIsNotComposed', () => {
|
||||
const markedWorkflow = '<!-- gsd:section id="a" when="always" -->\nbody\n<!-- /gsd:section -->\n';
|
||||
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 NON_WORKFLOW_DOC_REL = path.join('gsd-core', 'references', 'context-budget.md');
|
||||
const overlayRepo = buildOverlayRepo({
|
||||
'gsd-core/workflows/execute-phase.md': markedWorkflow,
|
||||
[NON_WORKFLOW_DOC_REL.split(path.sep).join('/')]: nonWorkflowDoc,
|
||||
});
|
||||
let dest;
|
||||
try {
|
||||
dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
||||
assert.equal(
|
||||
dest.result.status,
|
||||
0,
|
||||
`install must succeed: a non-workflow doc's marker-shaped line must never reach composeWorkflow\nstderr: ${dest.result.stderr}`,
|
||||
);
|
||||
|
||||
const emittedWorkflowPath = path.join(dest.configDir, PILOT_REL);
|
||||
assert.ok(fs.existsSync(emittedWorkflowPath), 'emitted execute-phase.md is missing');
|
||||
assert.equal(
|
||||
fs.readFileSync(emittedWorkflowPath, 'utf8'),
|
||||
'body\n',
|
||||
'gsd-core/workflows/execute-phase.md must still compose (markers stripped)',
|
||||
);
|
||||
|
||||
const emittedDocPath = path.join(dest.configDir, NON_WORKFLOW_DOC_REL);
|
||||
assert.ok(fs.existsSync(emittedDocPath), 'emitted context-budget.md is missing');
|
||||
assert.equal(
|
||||
fs.readFileSync(emittedDocPath, 'utf8'),
|
||||
nonWorkflowDoc,
|
||||
'a non-workflow .md must pass through composeWorkflow untouched, byte-identical, including its marker-shaped line',
|
||||
);
|
||||
} finally {
|
||||
cleanup(overlayRepo);
|
||||
if (dest) cleanup(dest.root);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Row 36: a malformed marker fails install loudly, with no partial emit ─
|
||||
|
||||
test('malformedMarkersFailInstallWithoutPartialEmit', () => {
|
||||
const malformed = '<!-- gsd:section id="broken" when="always" -->\nnever closed\n';
|
||||
const overlayRepo = buildOverlayRepo({ 'gsd-core/workflows/execute-phase.md': malformed });
|
||||
let dest;
|
||||
try {
|
||||
dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
||||
// stderr text is a child process's rendered prose, not a typed value
|
||||
// this test can assert on across the process boundary (CONTRIBUTING.md
|
||||
// "Prohibited: Raw Text Matching on Test Outputs" — err.reason is only
|
||||
// reachable in-process; see tests/workflow-fragments.test.cjs's REASON
|
||||
// assertions for the in-process equivalent of this same failure mode).
|
||||
// Assert typed, observable facts instead: the install process exits
|
||||
// non-zero, and no output file is written for the file that failed to
|
||||
// compose.
|
||||
assert.notEqual(
|
||||
dest.result.status,
|
||||
0,
|
||||
`install must fail loudly on a malformed marker, got exit 0\nstdout: ${dest.result.stdout}`,
|
||||
);
|
||||
const emittedPath = path.join(dest.configDir, PILOT_REL);
|
||||
assert.equal(
|
||||
fs.existsSync(emittedPath),
|
||||
false,
|
||||
'a half-composed execute-phase.md must never be written when composition throws',
|
||||
);
|
||||
} finally {
|
||||
cleanup(overlayRepo);
|
||||
if (dest) cleanup(dest.root);
|
||||
}
|
||||
});
|
||||
198
tests/workflow-fragments.property.test.cjs
Normal file
198
tests/workflow-fragments.property.test.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Property-based tests for src/workflow-fragments.cts (compiled to
|
||||
* gsd-core/bin/lib/workflow-fragments.cjs) — issue #2930 (epic #1671
|
||||
* Phase 3). Covers 50-test-matrix.md rows 30-31.
|
||||
*
|
||||
* Document-shaped generators (CONTRIBUTING.md "Fixture provenance #2371",
|
||||
* mirroring tests/context-predicates.property.test.cjs): these generators
|
||||
* build arbitrary markdown documents out of prose lines, fenced blocks
|
||||
* (whose contents — including marker LOOKALIKES — are always literal), and
|
||||
* well-formed `gsd:section` marker pairs. They are NOT seeded from this
|
||||
* module's own `composeWorkflow`/`renderFragments` — document shape (which
|
||||
* lines exist, in what order, wrapped in what fences) is generated
|
||||
* independently; only the STRIPPED-EXPECTATION bookkeeping (which line
|
||||
* indexes are real top-level marker lines) is computed alongside, from the
|
||||
* same generation step, never by round-tripping through the code under
|
||||
* test.
|
||||
*
|
||||
* Deterministic per CONTRIBUTING.md: seed and numRuns are pinned by
|
||||
* tests/helpers/fast-check-setup.cjs (seed 42, numRuns 200).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { parseWorkflowSections, composeWorkflow, WHEN_VOCABULARY } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||
|
||||
// Derived from the module's own frozen WHEN_VOCABULARY (DEFECT.GENERATIVE-FIX,
|
||||
// chore/2930 review): a hardcoded copy here would silently desync from the
|
||||
// production vocabulary the moment either side is edited without the other.
|
||||
const WHEN_VALUES = [...WHEN_VOCABULARY];
|
||||
|
||||
// ─── Document-shaped generators ────────────────────────────────────────────
|
||||
|
||||
// Plain-text charset that can never accidentally spell an HTML comment
|
||||
// delimiter or a fence delimiter — keeps every "decoy" line unambiguous.
|
||||
const proseTextArb = fc.stringMatching(/^[A-Za-z0-9 .,'":;()]{0,40}$/);
|
||||
|
||||
const idArb = fc.stringMatching(/^[a-z][a-z0-9]{0,5}$/);
|
||||
const whenArb = fc.constantFrom(...WHEN_VALUES);
|
||||
|
||||
// A prose line that LOOKS marker-adjacent but is never a real marker: a
|
||||
// heading, a blockquote, a table row, a mid-line backticked mention (extra
|
||||
// prose before/after disqualifies it structurally), or a one-line
|
||||
// `gsd:loop-host` comment (a different marker family entirely).
|
||||
const proseLineArb = fc.oneof(
|
||||
proseTextArb,
|
||||
proseTextArb.map((t) => `# ${t}`),
|
||||
proseTextArb.map((t) => `> ${t}`),
|
||||
proseTextArb.map((t) => `| ${t} | cell |`),
|
||||
idArb.map((id) => `See \`<!-- gsd:section id="${id}" when="always" -->\` for syntax.`),
|
||||
fc.constant('<!-- gsd:loop-host foo -->'),
|
||||
);
|
||||
|
||||
const fenceTickArb = fc.constantFrom('```', '~~~', '````');
|
||||
|
||||
// A fenced block: opener, 0-4 inner lines (prose OR a marker-lookalike full
|
||||
// line), matching closer using the SAME tick string — everything inside is
|
||||
// LITERAL regardless of shape (rows 5-8 of 50-test-matrix.md).
|
||||
function fenceBlockArb() {
|
||||
return fc
|
||||
.tuple(
|
||||
fenceTickArb,
|
||||
fc.array(
|
||||
fc.oneof(
|
||||
proseLineArb,
|
||||
idArb.map((id) => `<!-- gsd:section id="${id}" when="always" -->`),
|
||||
fc.constant('<!-- /gsd:section -->'),
|
||||
),
|
||||
{ minLength: 0, maxLength: 4 },
|
||||
),
|
||||
)
|
||||
.map(([tick, inner]) => [tick, ...inner, tick]);
|
||||
}
|
||||
|
||||
// A well-formed marker's interior: 0-3 items, each either a prose line or a
|
||||
// nested fenced block (which may itself contain marker lookalikes).
|
||||
function markerBodyArb() {
|
||||
return fc
|
||||
.array(fc.oneof({ arbitrary: proseLineArb, weight: 3 }, { arbitrary: fenceBlockArb(), weight: 1 }), {
|
||||
minLength: 0,
|
||||
maxLength: 3,
|
||||
})
|
||||
.map((items) => items.flat());
|
||||
}
|
||||
|
||||
// A top-level document block: a single prose line, a whole fenced block, or
|
||||
// a well-formed gsd:section marker pair (id assigned by the assembler for
|
||||
// document-wide uniqueness — see documentArb).
|
||||
const blockArb = fc.oneof(
|
||||
{ arbitrary: proseLineArb.map((line) => ({ kind: 'prose', lines: [line] })), weight: 3 },
|
||||
{ arbitrary: fenceBlockArb().map((lines) => ({ kind: 'prose', lines })), weight: 2 },
|
||||
{ arbitrary: fc.tuple(whenArb, markerBodyArb()).map(([when, body]) => ({ kind: 'marker', when, body })), weight: 2 },
|
||||
);
|
||||
|
||||
const eolArb = fc.constantFrom('\n', '\r\n');
|
||||
|
||||
/**
|
||||
* A whole document assembled from an arbitrary sequence of blocks. Returns
|
||||
* `{source, expectedStripped}`: `source` is the generated document text;
|
||||
* `expectedStripped` is `source` with exactly the REAL top-level marker
|
||||
* lines removed (computed from the same generation step, independent of
|
||||
* the code under test).
|
||||
*/
|
||||
function documentArb() {
|
||||
return fc
|
||||
.tuple(fc.array(blockArb, { minLength: 0, maxLength: 8 }), eolArb, fc.boolean())
|
||||
.map(([blocks, eol, trailingEol]) => {
|
||||
const allLines = [];
|
||||
const markerLineIndexes = new Set();
|
||||
let counter = 0;
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'prose') {
|
||||
allLines.push(...block.lines);
|
||||
continue;
|
||||
}
|
||||
const id = `sec${counter}`;
|
||||
counter += 1;
|
||||
markerLineIndexes.add(allLines.length);
|
||||
allLines.push(`<!-- gsd:section id="${id}" when="${block.when}" -->`);
|
||||
allLines.push(...block.body);
|
||||
markerLineIndexes.add(allLines.length);
|
||||
allLines.push('<!-- /gsd:section -->');
|
||||
}
|
||||
// Per-line records, each carrying ITS OWN terminator -- mirrors how
|
||||
// workflow-fragments.cts's own line splitter models termination, so
|
||||
// "expected" is computed by the same "remove this line INCLUDING its
|
||||
// terminator" rule the partition invariant defines. A naive
|
||||
// `filter().join(eol)` is WRONG here: `.join()` inserts a separator
|
||||
// only BETWEEN surviving elements, so it silently drops a survivor's
|
||||
// real trailing terminator whenever the (now-removed) line that used
|
||||
// to follow it supplied that separator — caught live by this
|
||||
// generator against a real marker-wrapped empty fence, where the
|
||||
// section's last body line sits immediately before the close marker.
|
||||
const records = allLines.map((text, idx) => ({
|
||||
text,
|
||||
eol: idx === allLines.length - 1 ? (trailingEol ? eol : '') : eol,
|
||||
}));
|
||||
const source = records.map((r) => r.text + r.eol).join('');
|
||||
const expectedStripped = records
|
||||
.filter((_, idx) => !markerLineIndexes.has(idx))
|
||||
.map((r) => r.text + r.eol)
|
||||
.join('');
|
||||
return { source, expectedStripped };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Row 30: parse/render round trip ───────────────────────────────────────
|
||||
|
||||
describe('property: parse/render round trip', () => {
|
||||
test('parseRenderRoundTripProperty', () => {
|
||||
fc.assert(
|
||||
fc.property(documentArb(), ({ source, expectedStripped }) => {
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, expectedStripped);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('parseRenderRoundTripProperty: no markers means exact identity', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(proseLineArb, { minLength: 0, maxLength: 10 }), eolArb, (lines, eol) => {
|
||||
const source = lines.join(eol);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 31: idempotency ────────────────────────────────────────────────────
|
||||
|
||||
describe('property: parse is idempotent over render', () => {
|
||||
test('parseIsIdempotentOverRender', () => {
|
||||
fc.assert(
|
||||
fc.property(documentArb(), ({ source }) => {
|
||||
const rendered = composeWorkflow(source);
|
||||
|
||||
// Composing an already-composed (marker-free) document is a no-op.
|
||||
assert.equal(composeWorkflow(rendered), rendered);
|
||||
|
||||
// Re-parsing the rendered output yields a fixed shape: zero
|
||||
// sections for an empty document, otherwise exactly ONE implicit
|
||||
// gap fragment whose body is the whole (now marker-free) document —
|
||||
// no marker survives composition to be re-recognized.
|
||||
const sectionsAfter = parseWorkflowSections(rendered);
|
||||
if (rendered === '') {
|
||||
assert.deepEqual(sectionsAfter, []);
|
||||
} else {
|
||||
assert.equal(sectionsAfter.length, 1);
|
||||
assert.equal(sectionsAfter[0].explicit, false);
|
||||
assert.equal(sectionsAfter[0].body, rendered);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
776
tests/workflow-fragments.test.cjs
Normal file
776
tests/workflow-fragments.test.cjs
Normal file
@@ -0,0 +1,776 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Example-based unit tests for src/workflow-fragments.cts (compiled to
|
||||
* gsd-core/bin/lib/workflow-fragments.cjs) — issue #2930 (epic #1671 Phase 3).
|
||||
*
|
||||
* Covers 50-test-matrix.md rows 1-29 and 37 (unit level). Rows 30/31
|
||||
* (property) live in workflow-fragments.property.test.cjs; rows 32-36
|
||||
* (install-level, real spawn-install) are out of scope for this module's
|
||||
* unit suite per ADR-1671 "Architecture and contracts".
|
||||
*
|
||||
* No source-grep (CONTRIBUTING.md): every assertion is on typed values
|
||||
* (WorkflowSection records, ComposeResult metadata, byte counts) — never on
|
||||
* rendered text via `.includes()`/`.match()`.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
parseWorkflowSections,
|
||||
toFragments,
|
||||
renderFragments,
|
||||
composeWorkflow,
|
||||
WHEN_VOCABULARY,
|
||||
REASON,
|
||||
} = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||
const { composeWithinBudget } = require('../gsd-core/bin/lib/context-composer.cjs');
|
||||
|
||||
const measureBytes = (text) => Buffer.byteLength(text, 'utf8');
|
||||
|
||||
/** Compose a document string from an array of lines, joined with '\n'. */
|
||||
const doc = (...lines) => lines.join('\n');
|
||||
|
||||
// ─── Row 1: unmarked document (the 88/89 production shape) ─────────────────
|
||||
|
||||
describe('unmarked document round trip', () => {
|
||||
test('unmarkedDocumentRoundTripsByteIdentical', () => {
|
||||
const source = doc(
|
||||
'# Some Workflow',
|
||||
'',
|
||||
'Ordinary prose describing the workflow.',
|
||||
'',
|
||||
'## A heading',
|
||||
'More prose.',
|
||||
'',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].id, 'gap-0');
|
||||
assert.equal(sections[0].body, source);
|
||||
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, source);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 2: single well-formed marker pair ──────────────────────────────────
|
||||
|
||||
describe('single marker pair', () => {
|
||||
test('singleMarkerPairStripsMarkersAndPreservesBody', () => {
|
||||
const source = doc(
|
||||
'before prose',
|
||||
'<!-- gsd:section id="sec-a" when="flag:--wave" -->',
|
||||
'body line 1',
|
||||
'body line 2',
|
||||
'<!-- /gsd:section -->',
|
||||
'after prose',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 3);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, 'before prose\n');
|
||||
assert.equal(sections[1].explicit, true);
|
||||
assert.equal(sections[1].id, 'sec-a');
|
||||
assert.equal(sections[1].when, 'flag:--wave');
|
||||
assert.equal(sections[1].body, 'body line 1\nbody line 2\n');
|
||||
assert.equal(sections[2].explicit, false);
|
||||
assert.equal(sections[2].body, 'after prose');
|
||||
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, 'before prose\nbody line 1\nbody line 2\nafter prose');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 3: several disjoint pairs + unmarked gaps ─────────────────────────
|
||||
|
||||
describe('multiple disjoint marker pairs', () => {
|
||||
test('multiplePairsPartitionDocumentExactly', () => {
|
||||
const source = doc(
|
||||
'gap0',
|
||||
'<!-- gsd:section id="a" when="always" -->',
|
||||
'bodyA',
|
||||
'<!-- /gsd:section -->',
|
||||
'gap1',
|
||||
'<!-- gsd:section id="b" when="state:has-prior-phases" -->',
|
||||
'bodyB',
|
||||
'<!-- /gsd:section -->',
|
||||
'gap2',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.deepEqual(
|
||||
sections.map((s) => ({ id: s.id, explicit: s.explicit })),
|
||||
[
|
||||
{ id: 'gap-0', explicit: false },
|
||||
{ id: 'a', explicit: true },
|
||||
{ id: 'gap-1', explicit: false },
|
||||
{ id: 'b', explicit: true },
|
||||
{ id: 'gap-2', explicit: false },
|
||||
],
|
||||
);
|
||||
|
||||
const markerLineRe = /^<!--\s*\/?gsd:section.*-->\s*$/;
|
||||
const expected = source
|
||||
.split('\n')
|
||||
.filter((line) => !markerLineRe.test(line))
|
||||
.join('\n');
|
||||
assert.equal(composeWorkflow(source), expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 4: the real pilot workflow ─────────────────────────────────────────
|
||||
|
||||
describe('real execute-phase.md', () => {
|
||||
// NOTE (chore/2930 retarget): the pilot moved from plan-phase.md to
|
||||
// execute-phase.md — plan-phase.md sits 36 B under the ADR-857 Phase-6
|
||||
// PRE_PHASE6 gate (tests/phase6-capstone-conformance.test.cjs) and cannot
|
||||
// absorb marker overhead, so the maintainer retargeted the pilot to
|
||||
// execute-phase.md (partial-wave, gap-closure-artifacts, regression-gate).
|
||||
test('pilotWorkflowParsesAndRendersToSourceMinusMarkers', () => {
|
||||
const pilotPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const original = fs.readFileSync(pilotPath, 'utf8');
|
||||
|
||||
// execute-phase.md carries the pilot's real marker pairs today: parsing
|
||||
// it must recognize exactly those three explicit sections, in document
|
||||
// order, and composing it must strip every marker line while leaving
|
||||
// every byte of body content untouched.
|
||||
const baselineSections = parseWorkflowSections(original, pilotPath);
|
||||
const baselineExplicit = baselineSections.filter((s) => s.explicit);
|
||||
assert.deepEqual(
|
||||
baselineExplicit.map((s) => s.id),
|
||||
['partial-wave', 'gap-closure-artifacts', 'regression-gate'],
|
||||
);
|
||||
const composedOriginal = composeWorkflow(original, { sourcePath: pilotPath });
|
||||
assert.equal(composedOriginal.includes('gsd:section'), false);
|
||||
assert.ok(Buffer.byteLength(composedOriginal, 'utf8') < Buffer.byteLength(original, 'utf8'));
|
||||
|
||||
// Wrap an ADDITIONAL, disjoint marker pair around an arbitrary interior
|
||||
// slice of real content that sits outside every existing marker pair
|
||||
// (lines 11-15, well before "partial-wave") and confirm it parses as a
|
||||
// fourth explicit section and composes to the SAME final output as the
|
||||
// unmodified file — every fragment is `verbatim` (row 23), so wrapping
|
||||
// already-included content in a new marker pair can never change what
|
||||
// is emitted, only how it is partitioned internally.
|
||||
const lines = original.split(/\r?\n/);
|
||||
const sliceStart = 10;
|
||||
const sliceEnd = 15;
|
||||
const markedLines = [
|
||||
...lines.slice(0, sliceStart),
|
||||
'<!-- gsd:section id="pilot-slice" when="always" -->',
|
||||
...lines.slice(sliceStart, sliceEnd),
|
||||
'<!-- /gsd:section -->',
|
||||
...lines.slice(sliceEnd),
|
||||
];
|
||||
const marked = markedLines.join('\n');
|
||||
|
||||
const sections = parseWorkflowSections(marked, pilotPath);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.deepEqual(
|
||||
explicitSections.map((s) => s.id),
|
||||
['pilot-slice', 'partial-wave', 'gap-closure-artifacts', 'regression-gate'],
|
||||
);
|
||||
assert.equal(explicitSections[0].body, lines.slice(sliceStart, sliceEnd).join('\n') + '\n');
|
||||
|
||||
const rendered = composeWorkflow(marked, { sourcePath: pilotPath });
|
||||
assert.equal(rendered, composedOriginal);
|
||||
assert.equal(measureBytes(rendered), measureBytes(composedOriginal));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 5/6: fence negative space ──────────────────────────────────────────
|
||||
|
||||
describe('marker lookalikes inside fences', () => {
|
||||
test('markerInsideFencedBlockIsLiteral', () => {
|
||||
const source = doc(
|
||||
'prose before',
|
||||
'```',
|
||||
'<!-- gsd:section id="fake" when="always" -->',
|
||||
'```',
|
||||
'prose after',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
|
||||
test('markerInsideFenceInsideSectionStaysLiteral', () => {
|
||||
const source = doc(
|
||||
'<!-- gsd:section id="real" when="always" -->',
|
||||
'intro',
|
||||
'```',
|
||||
'<!-- gsd:section id="fake" when="always" -->',
|
||||
'<!-- /gsd:section -->',
|
||||
'```',
|
||||
'outro',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, true);
|
||||
assert.equal(sections[0].id, 'real');
|
||||
assert.equal(
|
||||
sections[0].body,
|
||||
['intro', '```', '<!-- gsd:section id="fake" when="always" -->', '<!-- /gsd:section -->', '```', 'outro', ''].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 7/8: fence/comment mutual precedence ───────────────────────────────
|
||||
|
||||
describe('fence and comment mutual precedence', () => {
|
||||
test('fenceDelimiterInsideCommentDoesNotOpenFence', () => {
|
||||
const source = doc(
|
||||
'<!-- unrelated comment',
|
||||
'```',
|
||||
'still commented',
|
||||
'-->',
|
||||
'<!-- gsd:section id="after-comment" when="always" -->',
|
||||
'body',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
// If the fence delimiter on line 2 had wrongly opened a fence, the real
|
||||
// marker pair below would never be recognized (it would be swallowed as
|
||||
// "fence content" all the way to EOF).
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].id, 'after-comment');
|
||||
assert.equal(explicitSections[0].body, 'body\n');
|
||||
});
|
||||
|
||||
test('commentTokenInsideFenceDoesNotOpenComment', () => {
|
||||
const source = doc(
|
||||
'```',
|
||||
'<!-- unclosed comment token inside fence',
|
||||
'```',
|
||||
'<!-- gsd:section id="after-fence" when="always" -->',
|
||||
'body',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
// If the `<!--` inside the fence had wrongly opened a real comment, the
|
||||
// real marker pair below would never be recognized (swallowed as
|
||||
// "comment content" to EOF).
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].id, 'after-fence');
|
||||
assert.equal(explicitSections[0].body, 'body\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 9/10: other negative space ─────────────────────────────────────────
|
||||
|
||||
describe('loop-host and backtick negative space', () => {
|
||||
test('loopHostMarkerIsNotASectionMarker', () => {
|
||||
const source = doc(
|
||||
'<!-- gsd:loop-host',
|
||||
'step: plan',
|
||||
'points: plan:pre, plan:post',
|
||||
'-->',
|
||||
'<purpose>Do the thing.</purpose>',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
|
||||
test('backtickedMarkerMentionIsNotAMarker', () => {
|
||||
const source = doc(
|
||||
'See `<!-- gsd:section id="x" when="always" -->` for the marker syntax.',
|
||||
'And the close form is `<!-- /gsd:section -->` on its own line.',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 11-14: structural negatives with location ────────────────────────
|
||||
|
||||
describe('structural negatives throw with file + line', () => {
|
||||
test('unclosedSectionThrowsWithLocation', () => {
|
||||
const source = doc('prose', '<!-- gsd:section id="a" when="always" -->', 'body, never closed');
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.message.includes('workflow.md:2') && err.reason === REASON.UNCLOSED_SECTION,
|
||||
);
|
||||
});
|
||||
|
||||
test('unmatchedCloseThrowsWithLocation', () => {
|
||||
const source = doc('prose', '<!-- /gsd:section -->', 'more prose');
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.message.includes('workflow.md:2') && err.reason === REASON.UNMATCHED_CLOSE,
|
||||
);
|
||||
});
|
||||
|
||||
test('nestedSectionThrows', () => {
|
||||
const source = doc(
|
||||
'<!-- gsd:section id="outer" when="always" -->',
|
||||
'<!-- gsd:section id="inner" when="always" -->',
|
||||
'body',
|
||||
'<!-- /gsd:section -->',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.message.includes('workflow.md:2') && err.reason === REASON.NESTED_SECTION,
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicateSectionIdThrows', () => {
|
||||
const source = doc(
|
||||
'<!-- gsd:section id="dup" when="always" -->',
|
||||
'first',
|
||||
'<!-- /gsd:section -->',
|
||||
'<!-- gsd:section id="dup" when="flag:--wave" -->',
|
||||
'second',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
// Throws on the SECOND occurrence's line, not the first.
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.message.includes('workflow.md:4') && err.reason === REASON.DUPLICATE_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 15-18: attribute-shape negatives ─────────────────────────────────
|
||||
|
||||
describe('attribute-shape negatives', () => {
|
||||
test('missingIdAttributeThrows', () => {
|
||||
const source = '<!-- gsd:section when="always" -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.MISSING_ID,
|
||||
);
|
||||
});
|
||||
|
||||
test('missingWhenAttributeThrows', () => {
|
||||
const source = '<!-- gsd:section id="x" -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.MISSING_WHEN,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknownWhenValueThrows', () => {
|
||||
const source = '<!-- gsd:section id="x" when="flag:--nonexistent" -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
);
|
||||
});
|
||||
|
||||
test('whenValueWithBooleanOperatorThrows', () => {
|
||||
for (const when of ['flag:--wave && state:has-prior-phases', 'flag:--wave || state:has-prior-phases', '!flag:--wave']) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
`expected throw for when="${when}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformedAttributesThrows', () => {
|
||||
const source = '<!-- gsd:section id="x" when -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.MALFORMED_ATTRIBUTES,
|
||||
);
|
||||
});
|
||||
|
||||
test('unrecognizedAttributeThrows', () => {
|
||||
const source = '<!-- gsd:section id="x" when="always" bogus="1" -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNRECOGNIZED_ATTRIBUTE,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformedIdValueThrows', () => {
|
||||
const source = '<!-- gsd:section id="-bad-" when="always" -->\nbody\n<!-- /gsd:section -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.MALFORMED_ID,
|
||||
);
|
||||
});
|
||||
|
||||
test('closeMarkerWithAttributesThrows', () => {
|
||||
const source = '<!-- gsd:section id="x" when="always" -->\nbody\n<!-- /gsd:section foo="1" -->';
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.CLOSE_WITH_ATTRIBUTES,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 2/3 (chore/2930 review): REASON enum shape is locked ─────────────
|
||||
|
||||
describe('REASON enum is frozen and its shape is locked', () => {
|
||||
test('reasonEnumKeysAreLocked', () => {
|
||||
assert.equal(Object.isFrozen(REASON), true);
|
||||
assert.deepEqual(Object.keys(REASON).sort(), [
|
||||
'CLOSE_WITH_ATTRIBUTES',
|
||||
'DUPLICATE_ID',
|
||||
'MALFORMED_ATTRIBUTES',
|
||||
'MALFORMED_ID',
|
||||
'MISSING_ID',
|
||||
'MISSING_WHEN',
|
||||
'NESTED_SECTION',
|
||||
'UNCLOSED_SECTION',
|
||||
'UNKNOWN_WHEN',
|
||||
'UNMATCHED_CLOSE',
|
||||
'UNRECOGNIZED_ATTRIBUTE',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Doc/enum parity guard (DEFECT.GENERATIVE-FIX, code review #2930) ──────
|
||||
|
||||
describe('REASON enum and docs "Fails closed" bullets stay in parity', () => {
|
||||
test('everyReasonMemberIsDocumentedAndNoStaleBulletsRemain', () => {
|
||||
// allow-test-rule: docs-parity — the doc text IS the contract being checked here (#2930)
|
||||
const docPath = path.join(__dirname, '..', 'docs', 'reference', 'workflow-fragments.md');
|
||||
const docText = fs.readFileSync(docPath, 'utf8');
|
||||
|
||||
const sectionMatch = /## Fails closed\r?\n([\s\S]*?)\r?\n## /.exec(docText);
|
||||
assert.ok(sectionMatch, 'docs/reference/workflow-fragments.md must have a "## Fails closed" section');
|
||||
const sectionText = sectionMatch[1];
|
||||
|
||||
const enumMembers = Object.keys(REASON);
|
||||
// Key on the reason IDENTIFIER (e.g. `MALFORMED_ATTRIBUTES`) appearing in
|
||||
// a bullet, never on bullet prose — a reword of the human-readable
|
||||
// sentence must never falsely trip or falsely clear this guard.
|
||||
const undocumented = enumMembers.filter((name) => !sectionText.includes(name));
|
||||
|
||||
const mentionedIdentifiers = [...sectionText.matchAll(/`([A-Z][A-Z0-9_]*)`/g)].map((m) => m[1]);
|
||||
const staleMentions = mentionedIdentifiers.filter((name) => !enumMembers.includes(name));
|
||||
|
||||
assert.deepEqual(
|
||||
undocumented,
|
||||
[],
|
||||
`REASON member(s) missing a "Fails closed" bullet in docs/reference/workflow-fragments.md: ${undocumented.join(', ')}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
staleMentions,
|
||||
[],
|
||||
`"Fails closed" section mentions identifier(s) that are not REASON members (stale bullet?): ${staleMentions.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 19: frozen vocabulary ──────────────────────────────────────────────
|
||||
|
||||
describe('frozen when= vocabulary', () => {
|
||||
test('whenVocabularyIsFrozenAndLocked', () => {
|
||||
// WHEN_VOCABULARY is a frozen array (not an enum object) per the shipped
|
||||
// public API — lock the actual VALUES (sorted), not Object.keys() (which
|
||||
// for an array only reflects index positions '0','1',... and would not
|
||||
// catch a value being silently renamed). See the dispatch report for
|
||||
// this deliberate deviation from the test matrix's literal wording.
|
||||
assert.equal(Object.isFrozen(WHEN_VOCABULARY), true);
|
||||
assert.deepEqual(
|
||||
[...WHEN_VOCABULARY].sort(),
|
||||
['always', 'flag:--wave', 'state:gap-closure-phase', 'state:has-prior-phases'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 20-22: boundary documents ─────────────────────────────────────────
|
||||
|
||||
describe('boundary documents', () => {
|
||||
test('emptyDocumentProducesNoFragments', () => {
|
||||
const sections = parseWorkflowSections('');
|
||||
assert.deepEqual(sections, []);
|
||||
assert.equal(composeWorkflow(''), '');
|
||||
});
|
||||
|
||||
test('documentOfOnlyAMarkerPairYieldsEmptyBody', () => {
|
||||
const source = '<!-- gsd:section id="x" when="always" -->\n<!-- /gsd:section -->';
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, true);
|
||||
assert.equal(sections[0].id, 'x');
|
||||
assert.equal(sections[0].body, '');
|
||||
assert.equal(composeWorkflow(source), '');
|
||||
});
|
||||
|
||||
test('unclosedFenceAtEofDoesNotThrow', () => {
|
||||
const source = doc('prose', '```', 'never closed', '<!-- gsd:section id="x" when="always" -->');
|
||||
assert.doesNotThrow(() => parseWorkflowSections(source));
|
||||
const sections = parseWorkflowSections(source);
|
||||
// The whole document, including the marker-shaped line, is literal
|
||||
// fence content — one implicit gap fragment, byte-identical.
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 23-25: cross-platform + liberal formatting ───────────────────────
|
||||
|
||||
describe('cross-platform line endings and liberal marker formatting', () => {
|
||||
test('crlfDocumentRoundTripsByteIdentical', () => {
|
||||
const source = ['prose one', '<!-- gsd:section id="x" when="always" -->', 'crlf body', '<!-- /gsd:section -->', 'prose two'].join(
|
||||
'\r\n',
|
||||
);
|
||||
const rendered = composeWorkflow(source);
|
||||
const expected = source
|
||||
.split('\r\n')
|
||||
.filter((line) => !/^<!--\s*\/?gsd:section.*-->\s*$/.test(line))
|
||||
.join('\r\n');
|
||||
assert.equal(rendered, expected);
|
||||
});
|
||||
|
||||
test('mixedLineEndingsPreservedExactly', () => {
|
||||
const source = 'prose\r\n<!-- gsd:section id="x" when="always" -->\r\nbody one\nbody two\n<!-- /gsd:section -->\nprose two';
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].body, 'body one\nbody two\n');
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, 'prose\r\nbody one\nbody two\nprose two');
|
||||
});
|
||||
|
||||
test('attributeOrderAndSpacingAreAccepted', () => {
|
||||
const variants = [
|
||||
'<!-- gsd:section id="x" when="always" -->',
|
||||
'<!--gsd:section id="x" when="always"-->',
|
||||
'<!-- gsd:section when="always" id="x" -->',
|
||||
' <!-- gsd:section when="always" id="x" --> ',
|
||||
'<!--gsd:section when="always"id="x"-->',
|
||||
];
|
||||
for (const openLine of variants) {
|
||||
const source = `${openLine}\nbody\n<!-- /gsd:section -->`;
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1, `expected recognition for: ${openLine}`);
|
||||
assert.equal(explicitSections[0].id, 'x');
|
||||
assert.equal(explicitSections[0].when, 'always');
|
||||
// Re-render never leaks the original spacing — the marker is dropped
|
||||
// entirely, so only the body survives.
|
||||
assert.equal(composeWorkflow(source), 'body\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 26-29: budget boundary set (non-lossiness is structural) ─────────
|
||||
|
||||
describe('budget boundary set: nothing is ever trimmed', () => {
|
||||
const source = doc(
|
||||
'gap prose',
|
||||
'<!-- gsd:section id="a" when="always" -->',
|
||||
'section a body',
|
||||
'<!-- /gsd:section -->',
|
||||
'more gap prose',
|
||||
);
|
||||
|
||||
function composeAt(budget) {
|
||||
const sections = parseWorkflowSections(source);
|
||||
const fragments = toFragments(sections);
|
||||
return composeWithinBudget({ fragments, budget, measure: measureBytes, options: { charsPerUnit: 1 } });
|
||||
}
|
||||
|
||||
const baseline = (() => {
|
||||
const sections = parseWorkflowSections(source);
|
||||
const fragments = toFragments(sections);
|
||||
return fragments.reduce((sum, f) => sum + measureBytes(f.content), 0);
|
||||
})();
|
||||
|
||||
const expectedRendered = composeWorkflow(source);
|
||||
|
||||
test('nothingTrimmedWhenBudgetEqualsContent', () => {
|
||||
const result = composeAt(baseline);
|
||||
assert.deepEqual(result.metadata.omitted, []);
|
||||
assert.deepEqual(result.metadata.shrunk, []);
|
||||
assert.equal(renderFragments(result), expectedRendered);
|
||||
});
|
||||
|
||||
test('nothingTrimmedWhenBudgetIsOneUnderContent', () => {
|
||||
const result = composeAt(baseline - 1);
|
||||
assert.deepEqual(result.metadata.omitted, []);
|
||||
assert.deepEqual(result.metadata.shrunk, []);
|
||||
assert.equal(renderFragments(result), expectedRendered);
|
||||
});
|
||||
|
||||
test('nothingTrimmedWhenBudgetIsOneOverContent', () => {
|
||||
const result = composeAt(baseline + 1);
|
||||
assert.deepEqual(result.metadata.omitted, []);
|
||||
assert.deepEqual(result.metadata.shrunk, []);
|
||||
assert.equal(renderFragments(result), expectedRendered);
|
||||
});
|
||||
|
||||
test('nothingTrimmedUnderAbsurdBudgetPressure', () => {
|
||||
const result = composeAt(1);
|
||||
assert.deepEqual(result.metadata.omitted, []);
|
||||
assert.deepEqual(result.metadata.shrunk, []);
|
||||
assert.equal(result.metadata.hardFailed, false);
|
||||
assert.equal(renderFragments(result), expectedRendered);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 37: fs.readFileSync fault injection ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Simulate the realistic caller shape (read a workflow file, compose it,
|
||||
* write the composed result elsewhere) with `fs.readFileSync` monkeypatched
|
||||
* to throw. The monkeypatch is saved/restored HERE, in a helper, inside a
|
||||
* `finally` — never inside a test body, and never via chmod/permission
|
||||
* tricks (CLAUDE.md cross-platform fault-injection rule).
|
||||
*/
|
||||
function withInjectedReadFailure(fn) {
|
||||
const original = fs.readFileSync;
|
||||
fs.readFileSync = () => {
|
||||
throw new Error('injected read failure');
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.readFileSync = original;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FIX 4 (chore/2930 review): adversarial parser-input fixtures ─────────
|
||||
// CONTRIBUTING.md:484-513 requires adversarial fixtures for a new parser's
|
||||
// inputs. Each case here either round-trips byte-identical or produces the
|
||||
// correct typed REASON — never a message-text match.
|
||||
|
||||
describe('adversarial content bytes', () => {
|
||||
test('unicodeHeadingRoundTripsByteIdentical', () => {
|
||||
const source = doc(
|
||||
'# 見出し — Ünïcödé Hëading 🚀',
|
||||
'<!-- gsd:section id="sec" when="always" -->',
|
||||
'body with 中文, кириллица, emoji 🎉',
|
||||
'<!-- /gsd:section -->',
|
||||
'trailing プロース',
|
||||
);
|
||||
const expected = doc('# 見出し — Ünïcödé Hëading 🚀', 'body with 中文, кириллица, emoji 🎉', 'trailing プロース');
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, expected);
|
||||
assert.equal(measureBytes(rendered), measureBytes(expected));
|
||||
});
|
||||
|
||||
test('nulByteInBodyRoundTripsByteIdentical', () => {
|
||||
const source = `prose\0more\n<!-- gsd:section id="x" when="always" -->\nbody\0with\0nul\n<!-- /gsd:section -->\nafter\0`;
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].body, 'body\0with\0nul\n');
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, 'prose\0more\nbody\0with\0nul\nafter\0');
|
||||
});
|
||||
|
||||
test('unicodeReplacementCharacterRoundTripsByteIdentical', () => {
|
||||
const source = `prose <20> end\n<!-- gsd:section id="x" when="always" -->\nbody <20><>\n<!-- /gsd:section -->\nafter <20>`;
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, 'prose <20> end\nbody <20><>\nafter <20>');
|
||||
});
|
||||
|
||||
test('leadingByteOrderMarkRoundTripsByteIdentical', () => {
|
||||
const source = '# Heading\n<!-- gsd:section id="x" when="always" -->\nbody\n<!-- /gsd:section -->\ntail';
|
||||
const sections = parseWorkflowSections(source);
|
||||
const gaps = sections.filter((s) => !s.explicit);
|
||||
// The BOM is ordinary content of the leading gap — never stripped or
|
||||
// otherwise special-cased by this parser.
|
||||
assert.equal(gaps[0].body, '# Heading\n');
|
||||
const rendered = composeWorkflow(source);
|
||||
assert.equal(rendered, '# Heading\nbody\ntail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adversarial fence shapes', () => {
|
||||
test('fenceWithinFenceStaysLiteralUntilOuterCloser', () => {
|
||||
const source = doc(
|
||||
'prose before',
|
||||
'````',
|
||||
'```',
|
||||
'<!-- gsd:section id="fake" when="always" -->',
|
||||
'```',
|
||||
'````',
|
||||
'prose after',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
|
||||
test('tildeFenceHidesMarkerLookalike', () => {
|
||||
const source = doc('prose', '~~~', '<!-- gsd:section id="fake" when="always" -->', '~~~', 'prose after');
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
|
||||
test('indentedFenceUpToThreeSpacesHidesMarkerLookalike', () => {
|
||||
const source = doc('prose', ' ```', '<!-- gsd:section id="fake" when="always" -->', ' ```', 'prose after');
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adversarial line-ending shapes', () => {
|
||||
test('markerLineTerminatedByLoneCrIsNotRecognizedAsAMarker', () => {
|
||||
// A bare `\r` with no accompanying `\n` anywhere in the document is not
|
||||
// an EOL this grammar recognizes (only '' / '\n' / '\r\n' — see the
|
||||
// module doc comment). The marker-shaped text is therefore never on its
|
||||
// "own line" and must be left as ordinary literal content, not parsed
|
||||
// as an open marker.
|
||||
const source = '<!-- gsd:section id="x" when="always" -->\rbody, never a real line break';
|
||||
const sections = parseWorkflowSections(source);
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].explicit, false);
|
||||
assert.equal(sections[0].body, source);
|
||||
assert.equal(composeWorkflow(source), source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs.readFileSync fault injection mid-compose', () => {
|
||||
test('readFailureDuringCompositionLeavesNoPartialArtifact', (t) => {
|
||||
const tmpDir = createTempDir('gsd-wf-fault-');
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
const srcPath = path.join(tmpDir, 'source.md');
|
||||
const destPath = path.join(tmpDir, 'composed.md');
|
||||
fs.writeFileSync(srcPath, '<!-- gsd:section id="x" when="always" -->\nbody\n<!-- /gsd:section -->\n');
|
||||
|
||||
function readComposeWrite() {
|
||||
const content = fs.readFileSync(srcPath, 'utf8');
|
||||
const result = composeWorkflow(content, { sourcePath: srcPath });
|
||||
fs.writeFileSync(destPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => withInjectedReadFailure(() => readComposeWrite()),
|
||||
(err) => err instanceof Error && err.message === 'injected read failure',
|
||||
);
|
||||
assert.equal(fs.existsSync(destPath), false, 'no partial artifact must be written when the read fails');
|
||||
|
||||
// Restored correctly: a subsequent real call succeeds and DOES write.
|
||||
const result = readComposeWrite();
|
||||
assert.equal(fs.existsSync(destPath), true);
|
||||
assert.equal(result, 'body\n');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user