From b2f0fdf250f03707d933da5a508d3cb0a20f77f1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 8 May 2026 08:40:30 -0400 Subject: [PATCH] fix(sdk): anchor extractFrontmatter at file start (#3240) (#3247) * test: reproduce extractFrontmatter LAST-block bug (#3240) * fix(sdk): anchor extractFrontmatter at file start (#3240) * changeset: pr=3247 for #3240 --- .changeset/gentle-goats-fly.md | 5 +++ sdk/src/plan-parser.test.ts | 51 ++++++++++++++++++++++++++++ sdk/src/plan-parser.ts | 10 ++++-- sdk/src/query/frontmatter.test.ts | 56 +++++++++++++++++++++++++++++-- sdk/src/query/frontmatter.ts | 12 +++---- 5 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 .changeset/gentle-goats-fly.md diff --git a/.changeset/gentle-goats-fly.md b/.changeset/gentle-goats-fly.md new file mode 100644 index 000000000..d6624bb38 --- /dev/null +++ b/.changeset/gentle-goats-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3247 +--- +**`gsd-sdk query phase-plan-index` now reads frontmatter from the file's leading block** — plans with embedded YAML examples or markdown horizontal rules no longer silently mis-parse to wave=1, autonomous=true. diff --git a/sdk/src/plan-parser.test.ts b/sdk/src/plan-parser.test.ts index 12843fa61..8b827b187 100644 --- a/sdk/src/plan-parser.test.ts +++ b/sdk/src/plan-parser.test.ts @@ -217,6 +217,57 @@ describe('extractFrontmatter', () => { const result = extractFrontmatter(''); expect(result).toEqual({}); }); + + it('returns the LEADING block when body contains markdown horizontal rules', () => { + // Regression: LAST-block semantics picked up body separators as frontmatter (#3240) + const content = [ + '---', + 'wave: 3', + 'autonomous: false', + 'phase: 05-hardening', + '---', + '', + '## Section One', + '', + '---', + '', + '## Section Two', + '', + '---', + '', + 'body text', + ].join('\n'); + const result = extractFrontmatter(content); + expect(result.wave).toBe(3); + expect(result.autonomous).toBe(false); + expect(result.phase).toBe('05-hardening'); + }); + + it('returns the LEADING block when body contains embedded YAML in fenced code block', () => { + // Regression: LAST-block semantics matched YAML inside ```yaml fences (#3240) + const content = [ + '---', + 'wave: 2', + 'autonomous: true', + 'phase: 04-polish', + '---', + '', + '## Example', + '', + '```yaml', + '---', + 'name: example', + 'value: 99', + '---', + '```', + '', + 'More body text.', + ].join('\n'); + const result = extractFrontmatter(content); + expect(result.wave).toBe(2); + expect(result.autonomous).toBe(true); + expect(result.phase).toBe('04-polish'); + }); }); describe('parsePlan — frontmatter', () => { diff --git a/sdk/src/plan-parser.ts b/sdk/src/plan-parser.ts index 85d4d860e..a84397dd6 100644 --- a/sdk/src/plan-parser.ts +++ b/sdk/src/plan-parser.ts @@ -25,13 +25,17 @@ import type { * Uses a stack-based parser that handles nested objects, inline arrays, * multi-line arrays, and boolean/numeric coercion. Ported from the CJS * reference implementation with the same edge-case coverage. + * + * Anchored at the start of the file — only the leading `---...---` block is + * considered canonical frontmatter. Body `---` separators and embedded YAML + * inside fenced code blocks are never picked up. */ export function extractFrontmatter(content: string): Record { const frontmatter: Record = {}; - // Find ALL frontmatter blocks — if multiple exist (corruption), use the last one - const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)]; - const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null; + // Anchored at file start — only the leading ---...--- block is canonical frontmatter. + // Body `---` separators and embedded YAML inside fenced code blocks are not matched. + const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!match) return frontmatter; const yaml = match[1]; diff --git a/sdk/src/query/frontmatter.test.ts b/sdk/src/query/frontmatter.test.ts index 02c0e2893..d84bb44c1 100644 --- a/sdk/src/query/frontmatter.test.ts +++ b/sdk/src/query/frontmatter.test.ts @@ -66,10 +66,62 @@ describe('extractFrontmatter', () => { expect(result).toEqual({ items: ['one', 'two'] }); }); - it('uses the LAST block when multiple stacked blocks exist', () => { + it('uses the LEADING block when multiple stacked blocks exist', () => { + // extractFrontmatter is anchored at file start — leading block wins (#3240 fix) const content = '---\nold: data\n---\n---\nnew: data\n---\nbody'; const result = extractFrontmatter(content); - expect(result).toEqual({ new: 'data' }); + expect(result).toEqual({ old: 'data' }); + }); + + it('returns the LEADING block when body contains markdown horizontal rules', () => { + // Regression: LAST-block semantics picked up body separators as frontmatter (#3240) + const content = [ + '---', + 'wave: 3', + 'autonomous: false', + 'phase: 05-hardening', + '---', + '', + '## Section One', + '', + '---', + '', + '## Section Two', + '', + '---', + '', + 'body text', + ].join('\n'); + const result = extractFrontmatter(content); + expect(result.wave).toBe('3'); + expect(result.autonomous).toBe('false'); + expect(result.phase).toBe('05-hardening'); + }); + + it('returns the LEADING block when body contains embedded YAML in fenced code block', () => { + // Regression: LAST-block semantics matched YAML inside ```yaml fences (#3240) + const content = [ + '---', + 'wave: 2', + 'autonomous: true', + 'phase: 04-polish', + '---', + '', + '## Example', + '', + '```yaml', + '---', + 'name: example', + 'value: 99', + '---', + '```', + '', + 'More body text.', + ].join('\n'); + const result = extractFrontmatter(content); + expect(result.wave).toBe('2'); + expect(result.autonomous).toBe('true'); + expect(result.phase).toBe('04-polish'); }); it('handles empty-object-to-array conversion', () => { diff --git a/sdk/src/query/frontmatter.ts b/sdk/src/query/frontmatter.ts index 1de8eeca6..3a4b87049 100644 --- a/sdk/src/query/frontmatter.ts +++ b/sdk/src/query/frontmatter.ts @@ -174,20 +174,18 @@ export function extractFrontmatterLeading(content: string): Record { - // Find ALL frontmatter blocks. Use the LAST one (corruption recovery). - const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)]; - const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null; - if (!match) return {}; - - return parseFrontmatterYamlLines(match[1]); + return extractFrontmatterLeading(content); } // ─── stripFrontmatter ───────────────────────────────────────────────────────