* test: reproduce extractFrontmatter LAST-block bug (#3240) * fix(sdk): anchor extractFrontmatter at file start (#3240) * changeset: pr=3247 for #3240
This commit is contained in:
5
.changeset/gentle-goats-fly.md
Normal file
5
.changeset/gentle-goats-fly.md
Normal file
@@ -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.
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
|
||||
// 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];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -174,20 +174,18 @@ export function extractFrontmatterLeading(content: string): Record<string, unkno
|
||||
* - Nested objects via indentation
|
||||
* - Inline arrays: key: [a, b, c]
|
||||
* - Dash arrays with auto-conversion from empty objects
|
||||
* - Multiple stacked blocks (uses the LAST match)
|
||||
* - CRLF line endings
|
||||
* - Quoted value stripping
|
||||
*
|
||||
* Anchored at the start of the file — only the leading `---...---` block is
|
||||
* considered canonical frontmatter. Body `---` separators and embedded YAML
|
||||
* examples inside fenced code blocks are never picked up.
|
||||
*
|
||||
* @param content - File content potentially containing frontmatter
|
||||
* @returns Parsed frontmatter as a record, or empty object if none found
|
||||
*/
|
||||
export function extractFrontmatter(content: string): Record<string, unknown> {
|
||||
// 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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user