* 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>
777 lines
31 KiB
JavaScript
777 lines
31 KiB
JavaScript
'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');
|
||
});
|
||
});
|