fix(#2703): strip GSD-2 frontmatter with the canonical parser (#3027)

* test(#2703): failing-first coverage for CRLF frontmatter strip in SUMMARY.md

Drives the exported buildPlanningArtifacts seam. Rows for CRLF/LF parity,
stacked blocks and a leading BOM fail against the current hand-rolled
regex; the negative-space rows pin behavior that must not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2703): strip GSD-2 frontmatter with the canonical parser

buildSummaryMd matched the closing delimiter with a hardcoded bare \n, so a
CRLF-authored task summary never matched and fell through to the raw-passthrough
branch. The function then prepended its own block, emitting a SUMMARY.md with two
stacked frontmatter blocks and no warning.

Delegates to stripFrontmatter from frontmatter.cts -- the canonical, line-ending
tolerant primitive this repo already deduplicated once (#2143) -- instead of
adding another hand-rolled variant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2703): strip only the first frontmatter block in gsd2 import

Adversarial review caught a regression in the first cut: stripFrontmatter
loops by design, so a summary body opening with a thematic-break-delimited
section (--- / heading / ---) had that section silently deleted. The old
pre-#2703 regex preserved it, so shipping the loop would have traded one
silent corruption for another.

Adds an explicit { once } option to the canonical primitive -- default
behavior and the two existing callers are unchanged -- and has buildSummaryMd
opt in. A GSD-2 summary is an arbitrary user document, not a GSD artifact with
a known doubling failure mode, so a second block there is body content.

This also makes the acceptance criterion exact: CRLF now produces the same
result LF already produced, rather than a new result for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2703): backfill changeset pr number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-03 15:21:30 -04:00
committed by GitHub
parent 8ad7845f16
commit fd64389616
5 changed files with 230 additions and 9 deletions

View File

@@ -642,22 +642,29 @@ const FRONTMATTER_SCHEMAS: Record<string, { required: string[]; requiredValues?:
};
/**
* Strip ALL frontmatter blocks from the start of `content`.
* Strip frontmatter blocks from the start of `content`.
*
* Handles CRLF line endings and multiple stacked blocks (corruption
* recovery): greedily strips consecutive `---...---` blocks separated by
* optional whitespace, so a doubled/tripled frontmatter header (e.g. from a
* botched merge) is fully removed, not just the first block.
* Handles CRLF line endings and, by default, multiple stacked blocks
* (corruption recovery): greedily strips consecutive `---...---` blocks
* separated by optional whitespace, so a doubled/tripled frontmatter header
* (e.g. from a botched merge) is fully removed, not just the first block.
*
* Pass `{ once: true }` to stop after the first block. Callers whose input is
* an arbitrary user-authored document — rather than a GSD artefact with a
* known doubling failure mode — need this: a body that opens with a
* thematic-break-delimited section is lexically indistinguishable from a
* second frontmatter block, and the greedy loop deletes it silently (#2703).
*
* Canonical home for this primitive (#2143 audit dedup): previously
* duplicated byte-identically in both `state.cts` and `state-transition.cts`.
*/
function stripFrontmatter(content: string): string {
function stripFrontmatter(content: string, opts: { once?: boolean } = {}): string {
let result = content;
while (true) {
const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
if (stripped === result) break;
result = stripped;
if (opts.once) break;
}
return result;
}

View File

@@ -28,8 +28,11 @@ import { realClock } from './clock.cjs';
import coreUtilsMod = require('./core-utils.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
const { output } = ioMod;
const { transliterateForSlug } = coreUtilsMod;
const { stripFrontmatter } = frontmatterMod;
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -290,9 +293,22 @@ function buildPlanMd(task: TaskInfo, phasePrefix: string, planPrefix: string, ph
*/
function buildSummaryMd(task: TaskInfo, phasePrefix: string, planPrefix: string): string {
const raw = task.summary || '';
// Strip GSD-2 frontmatter block (--- ... ---) if present
const bodyMatch = raw.match(/^---[\s\S]*?---\n+([\s\S]*)$/);
const body = bodyMatch ? bodyMatch[1].trim() : raw.trim();
// Strip the GSD-2 frontmatter block via the canonical primitive (#2703). The
// previous local regex required a bare `\n` after the closing `---`, so a
// CRLF-authored summary never matched, fell through to the untouched-raw
// branch, and had its frontmatter emitted a second time inside the body of
// the document this function then wrapped in a fresh v1 block.
//
// `extractFrontmatter` — which the issue names — returns only the parsed
// object and never the body, so it cannot serve this call site;
// `stripFrontmatter` is the same module's canonical body primitive.
//
// `once` is load-bearing. A GSD-2 summary is an arbitrary user-authored
// document, not a GSD artefact with a known frontmatter-doubling failure
// mode, so a body opening with a thematic-break-delimited section
// (`---` / heading / `---`) is far likelier than a corrupt second header —
// and the default greedy loop would delete it without a trace.
const body = stripFrontmatter(raw, { once: true }).trim();
return [
'---',