fix(gen-skill-docs): quote frontmatter descriptions with interior colons (#1778)

Generated SKILL.md frontmatter emitted the catalog-trimmed description: as a
plain YAML scalar. A description with an interior ": " (e.g. "Ship workflow:
detect...") parses as a nested mapping under strict YAML loaders, so Codex/OpenAI
skill loading rejected those skills.

applyCatalogTrim now routes the value through toYamlInlineScalar, which quotes
(via JSON.stringify) only when a plain scalar would be invalid — interior ": ",
inline " #", leading indicator char, or surrounding whitespace. Strings that are
already valid plain scalars pass through unchanged to keep regen diffs small.

The frontmatter test now parses every generated block (Claude + Codex hosts) with
Bun.YAML.parse instead of string-checking that name:/description: substrings exist,
so the regression can't reappear. Runs under `bun test` (already in CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-30 10:38:01 -07:00
parent 73fa0be2f5
commit d3feac15ad
2 changed files with 58 additions and 5 deletions
+27 -1
View File
@@ -356,6 +356,28 @@ export function buildWhenToInvokeSection(parts: CatalogParts): string {
return lines.join('\n');
}
/**
* Render a string as a YAML inline scalar value (the text after `key: `),
* quoting only when a plain scalar would be invalid or ambiguous.
*
* The bug this guards (#1778): a description like "Ship workflow: detect..."
* emitted as a plain scalar has an interior ": " that a strict YAML parser
* (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
* "mapping values are not allowed in this context". When quoting is needed we
* fall back to JSON.stringify, which produces a double-quoted scalar that YAML
* accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
* are already valid plain scalars pass through unchanged to keep regen diffs small.
*/
export function toYamlInlineScalar(s: string): string {
const needsQuote =
s.length === 0 ||
s !== s.trim() || // leading/trailing whitespace
/:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
/\s#/.test(s) || // " #" → inline comment
/^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
return needsQuote ? JSON.stringify(s) : s;
}
/**
* Apply catalog trim to a SKILL.md body:
* - shorten frontmatter `description:` to lead + (gstack)
@@ -397,8 +419,12 @@ export function applyCatalogTrim(content: string, skillName: string): { content:
// Replace description in frontmatter — keep trailing newline so the next
// YAML field doesn't collide on the same line as the description value.
// Quote the value when it would be an invalid YAML plain scalar (the common
// case: an interior ": " like "Ship workflow: detect..." which a strict YAML
// parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
// only quotes when needed, so descriptions without special chars stay plain.
const newDesc = buildTrimmedDescription(parts);
const newFrontmatter = frontmatter.replace(descMatch[0], `description: ${newDesc}\n`);
const newFrontmatter = frontmatter.replace(descMatch[0], `description: ${toYamlInlineScalar(newDesc)}\n`);
let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
// Insert body section after frontmatter (after the closing ---\n and any