* 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>
199 lines
8.4 KiB
JavaScript
199 lines
8.4 KiB
JavaScript
'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);
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
});
|