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
This commit is contained in:
mastertyko 2026-03-28 01:09:48 +01:00 committed by GitHub
parent c88a9d2d4f
commit 6918fb76c6
2 changed files with 66 additions and 14 deletions

View file

@ -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<string, Record<string, string>> = {};
const result: Record<string, string[]> = {};
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<string, unknown> = {};
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<string, unknown>)[onlyKey];
}
}
}
typed[targetSection] = value;
} catch {
typed[section] = entries;
/* malformed section — skip */
}
}

View file

@ -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)", () => {