* 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:
461
src/workflow-fragments.cts
Normal file
461
src/workflow-fragments.cts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Workflow Fragments — in-file `<!-- gsd:section -->` marker parser/composer
|
||||
* for GSD workflow markdown files (ADR-1671 epic #1671, Phase 3 / issue #2930,
|
||||
* `.gsd/phase/chore-2930-fragmentize-xl-workflow/40-design.md`).
|
||||
*
|
||||
* Pure module: no I/O, no dependency beyond node built-ins and the shared
|
||||
* budget-trim seam `context-composer.cjs` (issue #2929). Emission order is
|
||||
* `parseWorkflowSections` -> `toFragments` -> `composeWithinBudget` ->
|
||||
* `renderFragments` (= `composeWorkflow`), run BEFORE the per-runtime
|
||||
* converters so a marker attribute never reaches a path-rewrite regex.
|
||||
*
|
||||
* ## Marker grammar (CLOSED)
|
||||
*
|
||||
* Open: a line whose only content (after trimming leading/trailing
|
||||
* whitespace) is `<!-- gsd:section id="<id>" when="<when>" -->`.
|
||||
* Close: a line whose only content is `<!-- /gsd:section -->`.
|
||||
*
|
||||
* Attribute order is free and inner spacing is flexible (Postel on FORMAT);
|
||||
* `id` and `when` VALUES are validated strictly and fail closed (Postel is
|
||||
* deliberately NOT applied to semantics — an unrecognized `when` is an
|
||||
* authoring instruction that must never be silently dropped). `id` matches
|
||||
* `/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/`; `when` must be `===` exactly one
|
||||
* entry of the frozen {@link WHEN_VOCABULARY} — no operators, no negation,
|
||||
* no nesting (Greenspun's Tenth Rule: extending the vocabulary is a
|
||||
* coordinated ADR amendment, never an organic edit).
|
||||
*
|
||||
* ## Partition invariant
|
||||
*
|
||||
* `parseWorkflowSections` returns sections that PARTITION the document:
|
||||
* every byte that is not part of a marker LINE belongs to exactly one
|
||||
* section, in document order. Text outside any marker pair becomes a
|
||||
* synthesized gap section (`explicit: false`, id `gap-<n>`, n from 0). A
|
||||
* marker line is removed IN FULL — text and its original line terminator —
|
||||
* so an unmarked document (88 of 89 workflows today) parses to exactly one
|
||||
* implicit gap fragment and composes back byte-identical.
|
||||
*
|
||||
* Line splitting is CRLF-aware per line (not `content.split('\n')`, which
|
||||
* would leave a stray `\r` glued to `.text` and cannot express a mixed
|
||||
* CRLF-marker/LF-body document): {@link splitLinesPreservingEol} records
|
||||
* each line's own terminator (`''`, `'\n'`, or `'\r\n'`) so reassembly is
|
||||
* exact regardless of line-ending mixture.
|
||||
*
|
||||
* ## Fence + comment interleaving (the highest-risk code here)
|
||||
*
|
||||
* A marker is structural only when it is NOT inside a fenced code block and
|
||||
* NOT inside an unrelated HTML comment (`<!-- gsd:loop-host ... -->` is a
|
||||
* different marker family entirely and is left untouched by construction —
|
||||
* it does not match the `gsd:section` token). Fences and comments are
|
||||
* scanned in ONE left-to-right interleaved pass with two mutually exclusive
|
||||
* states (`fence`, `inComment`), copying the discipline documented in
|
||||
* `src/context-predicates.cts`'s module comment (DEFECT.CONTEXT-PREDICATES-
|
||||
* COMMENT-FENCE-BLIND, #2928): while a fence is open, only a matching closer
|
||||
* can end it (a `<!--`/`-->` token on a fenced line is fence content, never
|
||||
* a comment boundary); while a comment is open, only a `-->` token can end
|
||||
* it (a fence delimiter inside it is comment content, never a fence
|
||||
* boundary); when neither is open, a comment opener is checked BEFORE a
|
||||
* fence opener (HTML comments are lexically outermost). A two-pass design
|
||||
* (mask one construct, then scan for the other) resolves this wrongly in
|
||||
* one direction and silently skips to EOF — that is the exact defect this
|
||||
* module avoids by construction. An unclosed fence at EOF does NOT throw;
|
||||
* everything after it is simply literal.
|
||||
*
|
||||
* One deliberate refinement beyond a naive "does the trimmed line START
|
||||
* WITH `<!--` and END WITH `-->`" check: whether a comment PERSISTS past
|
||||
* the current line is decided by `.includes('-->')` (does a close token
|
||||
* appear anywhere on the line), not by `.endsWith('-->')`. A line like
|
||||
* `<!-- TODO: fix --> some trailing prose` closes its comment on the same
|
||||
* line and must not swallow the rest of the document — it is simply not a
|
||||
* `gsd:section` marker (a marker's grammar requires the comment to be the
|
||||
* line's ONLY content), and is left as ordinary content in whichever
|
||||
* section/gap contains it.
|
||||
*
|
||||
* Known inherited limitation (shared with `context-predicates.cts`, not a
|
||||
* regression introduced here): comment-open detection is anchored to the
|
||||
* start of the trimmed line. An HTML comment that opens *mid-line* (prose
|
||||
* followed by an unclosed `<!--`) is not tracked, so a `gsd:section`-shaped
|
||||
* line appearing on a later line inside that comment would be misread as
|
||||
* real. GSD workflow markers are always authored on their own line, so this
|
||||
* does not affect the production shape; documented here rather than papered
|
||||
* over.
|
||||
*
|
||||
* ADR-457 build-at-publish: compiled by tsc to
|
||||
* gsd-core/bin/lib/workflow-fragments.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- context-composer.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
|
||||
import contextComposer = require('./context-composer.cjs');
|
||||
|
||||
/**
|
||||
* Frozen, CLOSED applicability vocabulary for the `when=` attribute.
|
||||
* Extending this list requires an ADR amendment, not an organic edit
|
||||
* (Greenspun's Tenth Rule — see the module doc comment).
|
||||
*/
|
||||
export const WHEN_VOCABULARY: readonly string[] = Object.freeze([
|
||||
'always',
|
||||
'flag:--wave',
|
||||
'state:gap-closure-phase',
|
||||
'state:has-prior-phases',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Frozen, stable reason codes for every `fail()` throw site in this module.
|
||||
* Tests assert via `assert.equal(err.reason, REASON.X)` rather than
|
||||
* regex-/substring-matching the human-readable message (CONTRIBUTING.md
|
||||
* "Prohibited: Raw Text Matching on Test Outputs"; shape copied from this
|
||||
* repo's own `gsd-core/bin/verify-reapply-patches.cjs` REASON enum) — a
|
||||
* message reword must never silently pass a test that exists to catch a
|
||||
* behavior regression.
|
||||
*
|
||||
* Adding a new reason requires updating this map AND the test that locks
|
||||
* `Object.keys(REASON).sort()` as a coordinated change.
|
||||
*/
|
||||
export const REASON = Object.freeze({
|
||||
UNCLOSED_SECTION: 'unclosed_section',
|
||||
UNMATCHED_CLOSE: 'unmatched_close',
|
||||
NESTED_SECTION: 'nested_section',
|
||||
DUPLICATE_ID: 'duplicate_id',
|
||||
MISSING_ID: 'missing_id',
|
||||
MISSING_WHEN: 'missing_when',
|
||||
MALFORMED_ID: 'malformed_id',
|
||||
UNKNOWN_WHEN: 'unknown_when',
|
||||
MALFORMED_ATTRIBUTES: 'malformed_attributes',
|
||||
UNRECOGNIZED_ATTRIBUTE: 'unrecognized_attribute',
|
||||
CLOSE_WITH_ATTRIBUTES: 'close_with_attributes',
|
||||
});
|
||||
|
||||
/** One partitioned section of a parsed workflow document. */
|
||||
export interface WorkflowSection {
|
||||
readonly id: string;
|
||||
readonly when: string;
|
||||
readonly body: string;
|
||||
/** false for a synthesized gap fragment (unmarked text between/around marker pairs). */
|
||||
readonly explicit: boolean;
|
||||
/** 1-based. The `<!-- gsd:section -->` open marker's line for an explicit section, or the first line of the gap for a synthesized one. */
|
||||
readonly startLine: number;
|
||||
}
|
||||
|
||||
/** One line of source content plus its ORIGINAL terminator, individually. */
|
||||
interface LineRecord {
|
||||
readonly text: string;
|
||||
readonly eol: '' | '\n' | '\r\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `content` into per-line records that each carry their OWN original
|
||||
* terminator, so CRLF/LF mixes and a missing trailing terminator reassemble
|
||||
* byte-for-byte via `record.text + record.eol` concatenation. See the
|
||||
* module doc comment's "Line splitting is CRLF-aware" note for why a bare
|
||||
* `content.split('\n')` cannot serve this.
|
||||
*
|
||||
* @param content - full source document text
|
||||
*/
|
||||
function splitLinesPreservingEol(content: string): LineRecord[] {
|
||||
const lines: LineRecord[] = [];
|
||||
let i = 0;
|
||||
while (i < content.length) {
|
||||
const nlIdx = content.indexOf('\n', i);
|
||||
if (nlIdx === -1) {
|
||||
lines.push({ text: content.slice(i), eol: '' });
|
||||
break;
|
||||
}
|
||||
const hasCr = content[nlIdx - 1] === '\r';
|
||||
const end = hasCr ? nlIdx - 1 : nlIdx;
|
||||
lines.push({ text: content.slice(i, end), eol: hasCr ? '\r\n' : '\n' });
|
||||
i = nlIdx + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Fence delimiter line matcher — mirrors `context-predicates.cts`'s (itself
|
||||
// mirroring `markdown-sectionizer.cts`'s `scanFencedBlocks`) exactly: >=3
|
||||
// backticks/tildes, <=3-space indent tolerance. This is a single-line
|
||||
// fence-OPENER/CLOSER probe, not a multiline fence-block-strip regex — it
|
||||
// does not trip `local/no-adhoc-markdown-parsing`'s fenceRegex fingerprint
|
||||
// (no `[\s\S]` multiline body in the pattern).
|
||||
const FENCE_DELIM_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
||||
|
||||
const OPEN_TAG_RE = /^gsd:section(?=\s|$)/;
|
||||
const CLOSE_TAG = '/gsd:section';
|
||||
const ID_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
|
||||
/**
|
||||
* Parse a candidate marker's attribute string (everything after `gsd:section`,
|
||||
* already trimmed) into a `key -> value` map, or `null` if it does not
|
||||
* consist entirely of zero-or-more `key="value"` tokens (attribute order is
|
||||
* free; spacing around `=` and between tokens is flexible — Postel on
|
||||
* FORMAT). Returns `null` on a duplicate attribute key too.
|
||||
*
|
||||
* @param attrsPart - the marker's attribute text, e.g. `id="x" when="always"`
|
||||
*/
|
||||
function parseAttrs(attrsPart: string): Map<string, string> | null {
|
||||
const attrs = new Map<string, string>();
|
||||
let remaining = attrsPart;
|
||||
const ATTR_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"/;
|
||||
while (remaining.length > 0) {
|
||||
const m = ATTR_RE.exec(remaining);
|
||||
if (!m) return null;
|
||||
const [full, key, value] = m;
|
||||
if (attrs.has(key)) return null;
|
||||
attrs.set(key, value);
|
||||
remaining = remaining.slice(full.length);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/** A `TypeError` carrying a stable {@link REASON} code alongside the human-readable message. */
|
||||
export interface WorkflowFragmentsError extends TypeError {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a `TypeError` naming `sourcePath` (when given) and the 1-based
|
||||
* `line`, carrying `reason` (one of {@link REASON}) as a typed property so
|
||||
* callers/tests never need to pattern-match the message prose.
|
||||
*/
|
||||
function fail(sourcePath: string | undefined, line: number, reason: string, message: string): never {
|
||||
const loc = sourcePath ? `${sourcePath}:${line}` : `line ${line}`;
|
||||
const err = new TypeError(`workflow-fragments: ${message} (${loc})`) as TypeError & { reason: string };
|
||||
err.reason = reason;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/** Result of classifying a complete one-line HTML comment's inner text. */
|
||||
type MarkerClassification =
|
||||
| { readonly kind: 'open'; readonly id: string; readonly when: string }
|
||||
| { readonly kind: 'close' }
|
||||
| { readonly kind: 'none' };
|
||||
|
||||
/**
|
||||
* Classify a complete one-line HTML comment's inner text (already stripped
|
||||
* of `<!--`/`-->` and trimmed) as a `gsd:section` open attempt, a close
|
||||
* marker, or "not a marker at all" — including `gsd:loop-host` and any
|
||||
* other unrelated comment, which never match the `gsd:section` token and
|
||||
* fall through to `{kind: 'none'}` untouched. Throws on any STRUCTURAL
|
||||
* violation of a recognized open/close attempt (fail-closed grammar).
|
||||
*
|
||||
* @param inner - the comment's inner text, e.g. `gsd:section id="x" when="always"`
|
||||
* @param sourcePath - optional file path named in thrown errors
|
||||
* @param lineNo - 1-based line number named in thrown errors
|
||||
*/
|
||||
function classifyMarker(inner: string, sourcePath: string | undefined, lineNo: number): MarkerClassification {
|
||||
if (inner === CLOSE_TAG) {
|
||||
return { kind: 'close' };
|
||||
}
|
||||
if (inner.startsWith(CLOSE_TAG) && /^\s/.test(inner.slice(CLOSE_TAG.length))) {
|
||||
fail(sourcePath, lineNo, REASON.CLOSE_WITH_ATTRIBUTES, 'close marker must not carry attributes');
|
||||
}
|
||||
if (!OPEN_TAG_RE.test(inner)) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const attrsPart = inner.slice('gsd:section'.length).trim();
|
||||
const attrs = parseAttrs(attrsPart);
|
||||
if (attrs === null) {
|
||||
fail(sourcePath, lineNo, REASON.MALFORMED_ATTRIBUTES, 'malformed section marker attributes');
|
||||
}
|
||||
const extraKeys = [...attrs.keys()].filter((k) => k !== 'id' && k !== 'when');
|
||||
if (extraKeys.length > 0) {
|
||||
fail(sourcePath, lineNo, REASON.UNRECOGNIZED_ATTRIBUTE, `unrecognized attribute "${extraKeys[0]}" on section marker`);
|
||||
}
|
||||
const id = attrs.get('id');
|
||||
const when = attrs.get('when');
|
||||
if (id === undefined) {
|
||||
fail(sourcePath, lineNo, REASON.MISSING_ID, 'section marker missing required "id" attribute');
|
||||
}
|
||||
if (when === undefined) {
|
||||
fail(sourcePath, lineNo, REASON.MISSING_WHEN, 'section marker missing required "when" attribute');
|
||||
}
|
||||
if (!ID_RE.test(id)) {
|
||||
fail(sourcePath, lineNo, REASON.MALFORMED_ID, `section marker "id" value "${id}" does not match ${ID_RE}`);
|
||||
}
|
||||
if (!WHEN_VOCABULARY.includes(when)) {
|
||||
fail(sourcePath, lineNo, REASON.UNKNOWN_WHEN, `section marker "when" value "${when}" is not in the frozen WHEN_VOCABULARY`);
|
||||
}
|
||||
return { kind: 'open', id, when };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a workflow document's `<!-- gsd:section -->` markers into a
|
||||
* document-order partition of {@link WorkflowSection}s. See the module doc
|
||||
* comment for the full grammar, partition invariant, and fence/comment
|
||||
* interleaving discipline.
|
||||
*
|
||||
* @param content - full workflow markdown source
|
||||
* @param sourcePath - optional file path named in thrown errors
|
||||
*/
|
||||
export function parseWorkflowSections(content: string, sourcePath?: string): WorkflowSection[] {
|
||||
const lines = splitLinesPreservingEol(content);
|
||||
const sections: WorkflowSection[] = [];
|
||||
|
||||
let fence: { char: '`' | '~'; len: number } | null = null;
|
||||
let inComment = false;
|
||||
let currentOpen: { id: string; when: string; startLineIndex: number } | null = null;
|
||||
const seenIds = new Set<string>();
|
||||
let gapCounter = 0;
|
||||
let cursor = 0;
|
||||
|
||||
const joinRange = (from: number, to: number): string => {
|
||||
let out = '';
|
||||
for (let k = from; k <= to; k++) {
|
||||
out += lines[k].text + lines[k].eol;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const flushGapBefore = (nextIndex: number): void => {
|
||||
if (nextIndex > cursor) {
|
||||
sections.push({
|
||||
id: `gap-${gapCounter}`,
|
||||
when: 'always',
|
||||
body: joinRange(cursor, nextIndex - 1),
|
||||
explicit: false,
|
||||
startLine: cursor + 1,
|
||||
});
|
||||
gapCounter += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNo = i + 1;
|
||||
const rawText = lines[i].text;
|
||||
|
||||
if (fence !== null) {
|
||||
// Inside a real fence: only a matching closer can end it. Any
|
||||
// `<!--`/`-->` on this line is fence content, never a comment
|
||||
// boundary (row 5/6 of 50-test-matrix.md).
|
||||
const m = FENCE_DELIM_RE.exec(rawText);
|
||||
if (m) {
|
||||
const char = m[2][0] as '`' | '~';
|
||||
const len = m[2].length;
|
||||
const trailing = m[3];
|
||||
if (char === fence.char && len >= fence.len && /^\s*$/.test(trailing)) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inComment) {
|
||||
// Inside a real (unrelated) comment: only '-->' can end it. Any
|
||||
// fence delimiter on this line is comment content, never a fence
|
||||
// boundary (row 7 of 50-test-matrix.md).
|
||||
if (rawText.includes('-->')) inComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
if (trimmed.startsWith('<!--')) {
|
||||
// Persistence is decided by whether a close token appears ANYWHERE on
|
||||
// the line, not by whether the line ENDS with one — see the module
|
||||
// doc comment's "deliberate refinement" note. Marker-hood additionally
|
||||
// requires the comment to be the line's ENTIRE content.
|
||||
const hasClose = trimmed.includes('-->');
|
||||
if (hasClose && trimmed.endsWith('-->')) {
|
||||
const inner = trimmed.slice(4, trimmed.length - 3).trim();
|
||||
const classification = classifyMarker(inner, sourcePath, lineNo);
|
||||
if (classification.kind === 'open') {
|
||||
if (currentOpen !== null) {
|
||||
fail(sourcePath, lineNo, REASON.NESTED_SECTION, `nested gsd:section marker (already inside "${currentOpen.id}")`);
|
||||
}
|
||||
if (seenIds.has(classification.id)) {
|
||||
fail(sourcePath, lineNo, REASON.DUPLICATE_ID, `duplicate section id "${classification.id}"`);
|
||||
}
|
||||
flushGapBefore(i);
|
||||
seenIds.add(classification.id);
|
||||
currentOpen = { id: classification.id, when: classification.when, startLineIndex: i };
|
||||
cursor = i + 1;
|
||||
} else if (classification.kind === 'close') {
|
||||
if (currentOpen === null) {
|
||||
fail(sourcePath, lineNo, REASON.UNMATCHED_CLOSE, 'unmatched /gsd:section close marker');
|
||||
}
|
||||
sections.push({
|
||||
id: currentOpen.id,
|
||||
when: currentOpen.when,
|
||||
body: joinRange(currentOpen.startLineIndex + 1, i - 1),
|
||||
explicit: true,
|
||||
startLine: currentOpen.startLineIndex + 1,
|
||||
});
|
||||
currentOpen = null;
|
||||
cursor = i + 1;
|
||||
}
|
||||
// classification.kind === 'none': ordinary self-contained comment
|
||||
// (e.g. a one-line `gsd:loop-host` or unrelated comment) — no state change.
|
||||
}
|
||||
if (!hasClose) {
|
||||
inComment = true; // multi-line: stays open until a later '-->'
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fenceMatch = FENCE_DELIM_RE.exec(rawText);
|
||||
if (fenceMatch) {
|
||||
const char = fenceMatch[2][0] as '`' | '~';
|
||||
const trailing = fenceMatch[3];
|
||||
// CommonMark §4.5: a backtick fence opener's info string must not
|
||||
// itself contain a backtick.
|
||||
if (!(char === '`' && trailing.includes('`'))) {
|
||||
fence = { char, len: fenceMatch[2].length };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentOpen !== null) {
|
||||
fail(sourcePath, currentOpen.startLineIndex + 1, REASON.UNCLOSED_SECTION, `unclosed gsd:section marker "${currentOpen.id}"`);
|
||||
}
|
||||
|
||||
flushGapBefore(lines.length);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map parsed sections to `context-composer` fragments. Every strategy is
|
||||
* `{kind: 'verbatim'}` (design row 23 / test matrix rows 26-29): non-
|
||||
* lossiness in this phase is a STRUCTURAL guarantee of the strategy choice,
|
||||
* never a large-budget trick.
|
||||
*
|
||||
* @param sections - document-order sections from {@link parseWorkflowSections}
|
||||
*/
|
||||
export function toFragments(sections: readonly WorkflowSection[]): contextComposer.Fragment[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
content: section.body,
|
||||
strategy: { kind: 'verbatim' as const },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate a {@link contextComposer.ComposeResult}'s fragment contents,
|
||||
* in declaration order, back into a document. Every fragment here is
|
||||
* `verbatim` with an empty wrapper, so this is a plain join.
|
||||
*
|
||||
* @param result - the plan returned by `composeWithinBudget`
|
||||
*/
|
||||
export function renderFragments(result: contextComposer.ComposeResult): string {
|
||||
return result.fragments.map((f) => f.content).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* THE emission entry point: parse -> toFragments -> composeWithinBudget ->
|
||||
* render. `budget` defaults to `Number.MAX_SAFE_INTEGER` (no pressure).
|
||||
* Because every fragment is `verbatim`, the output is identical regardless
|
||||
* of the budget value (design row 23) — this is never relied upon as the
|
||||
* source of non-lossiness; the strategy set is.
|
||||
*
|
||||
* @param content - full workflow markdown source
|
||||
* @param opts - `sourcePath` named in thrown parse errors; `budget` in bytes
|
||||
*/
|
||||
export function composeWorkflow(content: string, opts: { sourcePath?: string; budget?: number } = {}): string {
|
||||
const { sourcePath, budget = Number.MAX_SAFE_INTEGER } = opts;
|
||||
const sections = parseWorkflowSections(content, sourcePath);
|
||||
const fragments = toFragments(sections);
|
||||
const composed = contextComposer.composeWithinBudget({
|
||||
fragments,
|
||||
budget,
|
||||
measure: (text: string) => Buffer.byteLength(text, 'utf8'),
|
||||
options: { charsPerUnit: 1 },
|
||||
});
|
||||
return renderFragments(composed);
|
||||
}
|
||||
Reference in New Issue
Block a user