* test(#3145): bound the installer/runtime cluster onto the process seam Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73. Timeouts are sized from evidence already in the tree rather than a house default, because this wave spawns installers rather than git plumbing and an undersized bound does not catch a hang -- it manufactures CI flake, which is worse, since a flake gets re-run instead of investigated. install.test.cjs records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while another lane passed the same commit in 12.7s, so full installs are bound at 120000ms against that recorded incident. Also adds an auditable escape to the guard's timeout ceiling. The 600000ms cap was set in #3143 from partial evidence, but fragment-single-edit- propagation carries a documented, load-tested 900000ms bound on a run that chains a full build plus eight generators -- the guard would have rejected a correct timeout the moment that file left the allowlist. A value above the ceiling is now permitted only with an inline allow-spawn-timeout-ceiling marker carrying a non-empty reason. It raises the ceiling; it never waives the requirement for a bound, which is asserted directly. install-shared.cjs keeps its hand-rolled assert rather than routing through throwIfFailed: its message embeds both streams, and throwIfFailed carries only a trimmed stderr. The message now also names the outcome, so a bounded timeout reads as such across its 38 importers instead of as expected null to equal 0. * test(#3145): extract class-norm timeouts and correct the build-hooks sizing A pre-PR review found 52 copies of four class-norm timeout constants across this wave. These are not per-suite fixture bindings -- they are shared facts about how long a class of subprocess takes, derived from a recorded bench incident. That norm already moved once (60000 to 120000 after a real ETIMEDOUT), and 52 copies would have drifted the next time it moved. Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and converts the copies. A site that genuinely differs -- a real tsc compile, or regen:derived -- keeps its own local constant with its own justification. Also corrects a misclassification: scripts/build-hooks.js was sized as a build at 120000 in twelve places and 60000 in another, but it compiles and bundles nothing. Its own header says no bundling needed; it copies pre-built files and syntax-checks them with vm. Three different values bounded one script; now there is one. * test(#3145): fix red CI — lint self-match and a Windows chunk overrun Two failures on PR 3176. lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The fixture exists to prove an unrelated marker does NOT suppress the rule, so it carries that marker's literal text as test data. Split via concatenation, the same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own self-match problem. The explanatory comment needed the same treatment. The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped seven minutes before the kill, so this was an overrun rather than a slow chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs regen:derived bounded at 900000ms, which is larger than the whole chunk budget, so the chunk killer always fires first and it can never complete there. Both the test and that bound predate this change; modifying the file pulled it into the Windows targeted set and exposed it. Skipped on Windows with the reason recorded; the Linux lanes cover it. The 900000 bound and its ceiling marker are unchanged -- they are correct. * test(#3145): refresh the stale test-timings cost table The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs packs chunks by measured duration from tests/test-timings.json, and an unknown file falls back to the table's median weight -- advisory by design, but it silently underweights exactly the files that matter. Four of the failing chunk's 22 files were absent from the table, including the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s (it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s. Both were weighted as average, so the chunk's total weight read 53.68 against a budget of 60 and the packer produced a single chunk. Regenerated from a passing full-suite run, per the remedy the script itself documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since gen-test-timings.cjs replaces the table wholesale rather than merging. Proven against the real packer: the same 22 files now weigh 103.91 and split into two chunks. No logic, budget, or timeout was changed; raising a budget to make a red gate pass is not a fix. --------- Co-authored-by: sim <sim@local>
476 lines
23 KiB
JavaScript
476 lines
23 KiB
JavaScript
'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` /
|
|
* `linkOrCopyFile` now live in `./helpers/overlay-repo.cjs` (extracted,
|
|
* #2933, shared with `tests/fragment-single-edit-propagation.install.test.cjs`
|
|
* so the mechanism has exactly ONE implementation) — see that file's own doc
|
|
* comment for the hard-link-mirror mechanism, the Dirent `isFile()`/
|
|
* `isDirectory()` quirks it works around, the EXDEV/EPERM copy fallback, and
|
|
* why only `node_modules`/`.git` are symlinked at the top level. Every
|
|
* overlay-spawned installer below still 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 { runNode } = require('./helpers/process-seam.cjs');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { RUNTIME_META, runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs');
|
|
const { buildOverlayRepo } = require('./helpers/overlay-repo.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, '..');
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
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 was a genuinely unmarked
|
|
// file again, so row 33 used it instead of plan-phase.md. #2993 (epic #1671
|
|
// Phase 6.2) now marks plan-phase.md itself (6 sections, the fragmentization
|
|
// this change ships), so it is no longer a valid "genuinely unmarked" fixture
|
|
// either — retargeted a second time to discuss-phase.md, which carries no
|
|
// gsd:section markers as of this change.
|
|
const UNMARKED_REL = path.join('gsd-core', 'workflows', 'discuss-phase.md');
|
|
|
|
const RUNTIMES = Object.keys(RUNTIME_META);
|
|
|
|
// ─── Overlay-repo builder (rows 33/36) — see ./helpers/overlay-repo.cjs ────
|
|
|
|
/** 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 seamResult = runNode(args, {
|
|
cwd: root,
|
|
env: installerEnv({ HOME: root, USERPROFILE: root }),
|
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
});
|
|
const result = { status: seamResult.exitCode, stdout: seamResult.stdout, stderr: seamResult.stderr };
|
|
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', (t) => {
|
|
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',
|
|
});
|
|
t.after(() => cleanup(identityStubRepo));
|
|
|
|
for (const runtime of RUNTIMES) {
|
|
// Belt-and-braces cleanup: t.after() is the failure-path safety net (a
|
|
// thrown assertion still tears the temp install dirs down when the test
|
|
// returns), but t.after() alone defers EVERY registered cleanup across
|
|
// all ~18 runtimes until the whole test finishes, so up to 36 full
|
|
// install trees would coexist on disk at once. The eager cleanup() calls
|
|
// below bound peak disk to one iteration's trees on the success path;
|
|
// t.after() still fires afterward as a no-op (cleanup is idempotent on
|
|
// an already-removed path — see helpers.cjs).
|
|
const real = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(real.root));
|
|
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
|
t.after(() => cleanup(stub.root));
|
|
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})`,
|
|
);
|
|
cleanup(real.root);
|
|
cleanup(stub.root);
|
|
}
|
|
});
|
|
|
|
// ─── 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', (t) => {
|
|
const identityStubRepo = buildOverlayRepo({
|
|
'gsd-core/bin/lib/workflow-fragments.cjs': 'module.exports = { composeWorkflow: (c) => c };\n',
|
|
});
|
|
t.after(() => cleanup(identityStubRepo));
|
|
|
|
for (const runtime of RUNTIMES) {
|
|
const real = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(real.root));
|
|
const stub = spawnGlobalInstall(path.join(identityStubRepo, 'bin', 'install.js'), runtime);
|
|
t.after(() => cleanup(stub.root));
|
|
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 discuss-phase.md`);
|
|
assert.ok(fs.existsSync(stubPath), `${runtime}: stub install is missing discuss-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}: discuss-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}: discuss-phase.md content drifted between real compose and identity-stub compose`,
|
|
);
|
|
cleanup(real.root);
|
|
cleanup(stub.root);
|
|
}
|
|
});
|
|
|
|
// ─── Row 34: no gsd:section marker survives into any emitted artifact ─────
|
|
|
|
test('noSectionMarkerLeaksIntoEmittedArtifacts', (t) => {
|
|
for (const runtime of RUNTIMES) {
|
|
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(root));
|
|
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`,
|
|
);
|
|
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', (t) => {
|
|
for (const runtime of ['claude', 'opencode']) {
|
|
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
|
t.after(() => cleanup(root));
|
|
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`,
|
|
);
|
|
}
|
|
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', (t) => {
|
|
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,
|
|
});
|
|
t.after(() => cleanup(overlayRepo));
|
|
|
|
const dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
|
t.after(() => cleanup(dest.root));
|
|
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',
|
|
);
|
|
});
|
|
|
|
// ─── Row 36: a malformed marker fails install loudly, with no partial emit ─
|
|
|
|
// ─── Row 63 (50-test-matrix.md, issue #2932 Phase 5): extracting
|
|
// execute-phase.md's 3 sections must not perturb any OTHER workflow's
|
|
// emission — independence guard for the CRITICAL blast radius Phase 5's own
|
|
// design doc calls out. Pure-function check (no spawn needed): composeWorkflow
|
|
// is a documented no-op for every unmarked file, so any file other than the
|
|
// one Phase 5 migrates must still compose to itself, byte-identical. ────────
|
|
|
|
test('leavesUnmarkedWorkflowEmissionByteIdentical', () => {
|
|
const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows');
|
|
// execute-phase.md (#2932 Phase 5), plan-phase.md (#2993 Phase 6.2), and
|
|
// the thirteen workflows #2994 (epic #1671 Phase 6.3) fragmentizes onto the
|
|
// marker grammar are the files this repo has fragmentized with gsd:section
|
|
// markers — all excluded here since composeWorkflow is deliberately NOT a
|
|
// no-op for them. This set is DELIBERATELY hardcoded rather than derived
|
|
// from "does the file contain a marker": deriving it would make the guard
|
|
// tautological — a marker that LEAKED into an unrelated file via a bad
|
|
// extraction would just get silently excluded instead of failing the
|
|
// independence check this test exists to enforce. Update this list by hand
|
|
// whenever a workflow is legitimately marked.
|
|
const MARKED_WORKFLOWS = new Set([
|
|
'autonomous.md',
|
|
'code-review.md',
|
|
'complete-milestone.md',
|
|
'discuss-phase-assumptions.md',
|
|
'docs-update.md',
|
|
'execute-phase.md',
|
|
'new-milestone.md',
|
|
'new-project.md',
|
|
'plan-phase.md',
|
|
'progress.md',
|
|
'quick.md',
|
|
'review.md',
|
|
'transition.md',
|
|
'update.md',
|
|
'verify-work.md',
|
|
]);
|
|
const workflowFiles = fs
|
|
.readdirSync(workflowsDir, { withFileTypes: true })
|
|
.filter((d) => d.isFile() && d.name.endsWith('.md'))
|
|
.map((d) => d.name);
|
|
assert.ok(workflowFiles.length > MARKED_WORKFLOWS.size, 'sanity: there must be more than the marked workflows on disk');
|
|
|
|
let checkedCount = 0;
|
|
for (const fileName of workflowFiles) {
|
|
if (MARKED_WORKFLOWS.has(fileName)) continue;
|
|
const filePath = path.join(workflowsDir, fileName);
|
|
const source = fs.readFileSync(filePath, 'utf8');
|
|
const composed = composeWorkflow(source, { sourcePath: filePath });
|
|
assert.equal(
|
|
composed,
|
|
source,
|
|
`${fileName}: emission drifted — a marked workflow's extraction must not touch any other workflow`,
|
|
);
|
|
checkedCount += 1;
|
|
}
|
|
assert.equal(
|
|
checkedCount,
|
|
workflowFiles.length - MARKED_WORKFLOWS.size,
|
|
'every workflow file except the marked ones must have been checked',
|
|
);
|
|
});
|
|
|
|
test('malformedMarkersFailInstallWithoutPartialEmit', (t) => {
|
|
const malformed = '<!-- gsd:section id="broken" when="always" -->\nnever closed\n';
|
|
const overlayRepo = buildOverlayRepo({ 'gsd-core/workflows/execute-phase.md': malformed });
|
|
t.after(() => cleanup(overlayRepo));
|
|
|
|
const dest = spawnGlobalInstall(path.join(overlayRepo, 'bin', 'install.js'), 'claude');
|
|
t.after(() => cleanup(dest.root));
|
|
// 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',
|
|
);
|
|
});
|