From 6918fb76c6e408d1d9b5a251c3b8199200e51602 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:09:48 +0100 Subject: [PATCH] fix(gsd): parse raw YAML under preference headings (#2794) Accept raw YAML blocks beneath markdown preference headings while preserving legacy heading-list parsing. Closes #2787 --- src/resources/extensions/gsd/preferences.ts | 46 +++++++++++++------ .../extensions/gsd/tests/preferences.test.ts | 34 ++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index c1699160b..58badbd95 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -22,6 +22,7 @@ import { normalizeStringArray } from "../shared/format-utils.js"; import { resolveProfileDefaults as _resolveProfileDefaults } from "./preferences-models.js"; import { + KNOWN_PREFERENCE_KEYS, MODE_DEFAULTS, type WorkflowMode, type GSDPreferences, @@ -250,7 +251,7 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { * - planner: sonnet */ function parseHeadingListFormat(content: string): GSDPreferences { - const result: Record> = {}; + const result: Record = {}; let currentSection: string | null = null; for (const rawLine of content.split('\n')) { @@ -258,27 +259,44 @@ function parseHeadingListFormat(content: string): GSDPreferences { const headingMatch = line.match(/^##\s+(.+)$/); if (headingMatch) { currentSection = headingMatch[1].trim().toLowerCase().replace(/\s+/g, '_'); + if (!result[currentSection]) result[currentSection] = []; continue; } - if (currentSection) { - const itemMatch = line.match(/^-\s+([^:]+):\s*(.*)$/); - if (itemMatch) { - if (!result[currentSection]) result[currentSection] = {}; - const value = itemMatch[2].trim(); - // Coerce "true"/"false" strings and numbers - result[currentSection][itemMatch[1].trim()] = value; - } + if (currentSection && line.trim() && !line.trimStart().startsWith('#')) { + result[currentSection].push(line); } } - // Convert string values to appropriate types via YAML parser for each section const typed: Record = {}; - for (const [section, entries] of Object.entries(result)) { - const yamlLines = Object.entries(entries).map(([k, v]) => `${k}: ${v}`).join('\n'); + for (const [section, lines] of Object.entries(result)) { + if (lines.length === 0) continue; + + const usesLegacyListItems = lines.every((line) => /^\s*-\s+[^:]+:\s*.*$/.test(line)); + const yamlBlock = usesLegacyListItems + ? lines.map((line) => line.replace(/^\s*-\s+/, '')).join('\n') + : lines.join('\n'); + try { - typed[section] = parseYaml(yamlLines); + const parsed = parseYaml(yamlBlock); + if (typeof parsed !== 'object' || parsed === null) continue; + + let targetSection = section; + let value: unknown = parsed; + + if (!Array.isArray(parsed)) { + const keys = Object.keys(parsed); + if (keys.length === 1) { + const [onlyKey] = keys; + if (onlyKey === section || (!KNOWN_PREFERENCE_KEYS.has(section) && KNOWN_PREFERENCE_KEYS.has(onlyKey))) { + targetSection = onlyKey; + value = (parsed as Record)[onlyKey]; + } + } + } + + typed[targetSection] = value; } catch { - typed[section] = entries; + /* malformed section — skip */ } } diff --git a/src/resources/extensions/gsd/tests/preferences.test.ts b/src/resources/extensions/gsd/tests/preferences.test.ts index e01035460..ff150440d 100644 --- a/src/resources/extensions/gsd/tests/preferences.test.ts +++ b/src/resources/extensions/gsd/tests/preferences.test.ts @@ -352,6 +352,40 @@ test("handles empty models config", () => { assert.equal(prefs!.models, undefined); }); +test("parses raw YAML blocks under headings", () => { + const content = `## Parallel +enabled: true +max_workers: 3 +`; + const prefs = parsePreferencesMarkdown(content); + assert.notEqual(prefs, null); + assert.equal(prefs!.parallel?.enabled, true); + assert.equal(prefs!.parallel?.max_workers, 3); +}); + +test("unwraps nested top-level preference key under descriptive headings", () => { + const content = `## Parallel Orchestration +parallel: + enabled: true + max_workers: 3 +`; + const prefs = parsePreferencesMarkdown(content); + assert.notEqual(prefs, null); + assert.equal(prefs!.parallel?.enabled, true); + assert.equal(prefs!.parallel?.max_workers, 3); +}); + +test("preserves legacy heading list format", () => { + const content = `## Git +- isolation: branch +- auto_push: true +`; + const prefs = parsePreferencesMarkdown(content); + assert.notEqual(prefs, null); + assert.equal(prefs!.git?.isolation, "branch"); + assert.equal(prefs!.git?.auto_push, true); +}); + // ── Warn-once for unrecognized format (#2373) ──────────────────────────────── test("unrecognized format warning is emitted at most once (#2373)", () => {