Skip to content

fix(command-generation): escape YAML frontmatter name in codebuddy/crush/lingma/qoder - #1330

Closed
seattled23 wants to merge 1 commit into
Fission-AI:mainfrom
seattled23:fix/yaml-frontmatter-escaping
Closed

fix(command-generation): escape YAML frontmatter name in codebuddy/crush/lingma/qoder#1330
seattled23 wants to merge 1 commit into
Fission-AI:mainfrom
seattled23:fix/yaml-frontmatter-escaping

Conversation

@seattled23

@seattled23 seattled23 commented Jul 8, 2026

Copy link
Copy Markdown

Problem

The codebuddy, crush, lingma, and qoder adapters interpolate name: ${content.name} raw into command frontmatter. Every OPSX command name contains a colon (OPSX: Explore, OPSX: Apply, …), so the emitted frontmatter is:

name: OPSX: Explore

which is invalid YAML — parsers throw mapping values are not allowed here (reproduced). On a default openspec init for any of those 4 tools, the generated slash-commands ship broken into the user's repo.

Fix

Route the field through the existing escapeYamlValue helper — the same one claude / windsurf / trae already use. OPSX: Explore"OPSX: Explore", valid YAML, value preserved.

The prior escapeYamlValue refactor (#1204 / #1205) wired the helper into pi / claude / qwen but didn't reach these 4 adapters.

Test

Adds tests that feed a colon-bearing name through each of the 4 adapters, extract the frontmatter, parse it with the real yaml library, and assert the name round-trips.

Note on scope (follow-up, not in this PR)

This PR fixes the field that actually breaks today — name, because it's the one carrying a colon. For completeness: ~11 other adapters still interpolate description: ${content.description} raw (amazon-q, antigravity, auggie, codex, continue, factory, github-copilot, iflow, junie, kiro, opencode), and iflow also emits category: ${content.category} raw. None break today (no current description/category value contains a colon), so it's a latent instance of the same class rather than a live bug. Happy to follow up with a sweep if you'd like it in one pass.

Co-authored-by: Sōren Vale soren@tessara.us

Summary by CodeRabbit

  • Bug Fixes
    • Improved generated command files so titles, descriptions, categories, and tags are safely escaped in YAML.
    • Fixed cases where special characters like colons could break command metadata rendering.
  • Tests
    • Added coverage to verify generated command metadata remains valid YAML.
    • Updated expectations to match the new escaping behavior.

The codebuddy, crush, lingma, and qoder adapters interpolated
`name: ${content.name}` raw into command frontmatter. Every OPSX command name
contains a colon ("OPSX: Explore", "OPSX: Apply", ...), so the emitted
frontmatter is `name: OPSX: Explore` — invalid YAML ("mapping values are not
allowed here"). On a default `openspec init` for any of those 4 tools, the
generated slash-commands ship broken to the user's repo.

Fix routes the field through the existing `escapeYamlValue` helper, matching
the claude/windsurf/trae adapters. The prior escapeYamlValue refactor
(#1204/#1205) wired the helper into pi/claude/qwen but did not reach these 4.
Adds tests that parse each adapter's frontmatter with the real YAML library and
assert the name round-trips.

Co-authored-by: Sōren Vale <soren@tessara.us>
@seattled23
seattled23 requested a review from TabishB as a code owner July 8, 2026 17:21
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Four command-generation adapters (CodeBuddy, Crush, Lingma, Qoder) now import and apply escapeYamlValue to sanitize name, description, category, and tags fields when generating YAML frontmatter, replacing direct string interpolation. Tests are updated to reflect new escaping behavior and add YAML parseability validation.

Changes

YAML frontmatter escaping across adapters

Layer / File(s) Summary
CodeBuddy escaping
src/core/command-generation/adapters/codebuddy.ts
Imports escapeYamlValue and applies it to name and description frontmatter fields.
Crush escaping
src/core/command-generation/adapters/crush.ts
Imports escapeYamlValue and applies it to name, description, category, and each tag.
Lingma escaping
src/core/command-generation/adapters/lingma.ts
Imports escapeYamlValue and applies it to name, description, category, and each tag in formatFile.
Qoder escaping
src/core/command-generation/adapters/qoder.ts
Imports escapeYamlValue and applies it to name, description, category, and each tag.
Adapter test updates
test/core/command-generation/adapters.test.ts
Imports yaml's parse and lingmaAdapter, updates the codebuddy description assertion to expect unquoted output, and adds a new suite verifying that adapters produce parseable YAML frontmatter preserving colon-containing name values.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • Fission-AI/OpenSpec#1240: Both PRs depend on the shared escapeYamlValue utility for generating YAML frontmatter safely across adapters.
  • Fission-AI/OpenSpec#1276: Both PRs update YAML frontmatter escaping via escapeYamlValue and extend the same adapter test suite.

Suggested reviewers: TabishB

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: escaping YAML frontmatter in the command-generation adapters.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/core/command-generation/adapters.test.ts (1)

844-879: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider expanding coverage to other escaped fields.

The suite name says "colon-bearing fields" (plural) but only name is tested with a colon. Since description, category, and tags are also now routed through escapeYamlValue, adding at least one case where those fields contain YAML-special characters (e.g., colons, brackets) would strengthen the round-trip guarantee and catch regressions if an adapter reverts to raw interpolation.

♻️ Suggested additional test case
     it(`${name} emits parseable YAML frontmatter when the name contains a colon`, () => {
       const contentWithColon: CommandContent = {
         ...sampleContent,
         name: 'OPSX: Explore',
       };

       const output = adapter.formatFile(contentWithColon);
       const frontmatter = extractFrontmatter(output);

       // Must parse without throwing and preserve the exact name (colon intact).
       const parsed = parseYaml(frontmatter) as Record<string, unknown>;
       expect(parsed.name).toBe('OPSX: Explore');
     });
+
+    it(`${name} preserves all fields when multiple contain YAML-special characters`, () => {
+      const contentWithSpecialChars: CommandContent = {
+        ...sampleContent,
+        name: 'OPSX: Explore',
+        description: 'Explore: think, plan & act',
+        category: 'Workflow: Core',
+        tags: ['opsx:explore', 'plan & act'],
+      };
+
+      const output = adapter.formatFile(contentWithSpecialChars);
+      const frontmatter = extractFrontmatter(output);
+
+      const parsed = parseYaml(frontmatter) as Record<string, unknown>;
+      expect(parsed.name).toBe('OPSX: Explore');
+      expect(parsed.description).toBe('Explore: think, plan & act');
+      expect(parsed.category).toBe('Workflow: Core');
+      expect(parsed.tags).toEqual(['opsx:explore', 'plan & act']);
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/command-generation/adapters.test.ts` around lines 844 - 879, Expand
the YAML frontmatter coverage in the adapters test by adding cases for other
escaped fields besides `name`, since `description`, `category`, and `tags` also
flow through `escapeYamlValue`. Update the
`yamlFrontmatterAdapters`/`extractFrontmatter` test block to include at least
one input where `description`, `category`, or `tags` contains YAML-special
characters like colons or brackets, then parse the emitted frontmatter with
`parseYaml` and assert the value round-trips correctly. Use the existing adapter
helpers (`formatFile`, `parseYaml`, `extractFrontmatter`) so the new test stays
aligned with the current colon-handling checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/core/command-generation/adapters.test.ts`:
- Around line 844-879: Expand the YAML frontmatter coverage in the adapters test
by adding cases for other escaped fields besides `name`, since `description`,
`category`, and `tags` also flow through `escapeYamlValue`. Update the
`yamlFrontmatterAdapters`/`extractFrontmatter` test block to include at least
one input where `description`, `category`, or `tags` contains YAML-special
characters like colons or brackets, then parse the emitted frontmatter with
`parseYaml` and assert the value round-trips correctly. Use the existing adapter
helpers (`formatFile`, `parseYaml`, `extractFrontmatter`) so the new test stays
aligned with the current colon-handling checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 37644f05-a474-4dbf-942d-8d3ff54dc113

📥 Commits

Reviewing files that changed from the base of the PR and between 93e27a7 and c12a8a5.

📒 Files selected for processing (5)
  • src/core/command-generation/adapters/codebuddy.ts
  • src/core/command-generation/adapters/crush.ts
  • src/core/command-generation/adapters/lingma.ts
  • src/core/command-generation/adapters/qoder.ts
  • test/core/command-generation/adapters.test.ts

@clay-good

Copy link
Copy Markdown
Collaborator

Verified as superseded by the broader fix that has already merged to main; this is ready to close while preserving credit for the concrete bug and test strategy contributed here.

Proof:

  • PR fix(adapters): escape YAML frontmatter values consistently across all command adapters #1447 merged commit fb196995 and applies shared YAML escaping across all command adapters, including the 4 identified here: fb19699
  • On current main, CodeBuddy escapes both name and description:
    formatFile(content: CommandContent): string {
    return `---
    name: ${escapeYamlValue(content.name)}
    description: ${escapeYamlValue(content.description)}
    argument-hint: "[command arguments]"
  • Crush, Lingma, and Qoder likewise route their frontmatter fields through the shared escaping helpers:
    • formatFile(content: CommandContent): string {
      return `---
      name: ${escapeYamlValue(content.name)}
      description: ${escapeYamlValue(content.description)}
      category: ${escapeYamlValue(content.category)}
      tags: ${formatTagsArray(content.tags)}
      ---
    • formatFile(content: CommandContent): string {
      return `---
      name: ${escapeYamlValue(content.name)}
      description: ${escapeYamlValue(content.description)}
      category: ${escapeYamlValue(content.category)}
      tags: ${formatTagsArray(content.tags)}
      ---
    • formatFile(content: CommandContent): string {
      return `---
      name: ${escapeYamlValue(content.name)}
      description: ${escapeYamlValue(content.description)}
      category: ${escapeYamlValue(content.category)}
      tags: ${formatTagsArray(content.tags)}
      ---
  • Main now has a registry-driven YAML-frontmatter matrix that parses emitted YAML and exercises every content-derived string field, a stronger/generalized form of this PR's proposed regression test:
    describe('YAML frontmatter escaping across adapters', () => {
    // Derived from the registry, not hand-listed: a newly registered adapter
    // must be covered by default. Adding one that emits no YAML frontmatter is
    // then a deliberate act of adding it here.
    const NON_YAML_ADAPTERS = ['cline', 'kilocode', 'roocode', 'gemini'];
    const yamlAdapters = CommandAdapterRegistry.getAll().filter(
    (adapter) => !NON_YAML_ADAPTERS.includes(adapter.toolId)
    );
    /**
    * Builds a CommandContent whose every string field carries `marker`.
    */
    function contentWith(marker: string): CommandContent {
    return {
    id: 'explore',
    name: marker,
    description: marker,
    category: marker,
    tags: [marker, 'explore'],
    body: 'Body text',
    };
    }
    /**
    * Returns the frontmatter fields this adapter fills from CommandContent,
    * found by rendering two different markers and keeping the fields that
    * change. Fields derived from the command id (Cursor's `name`/`id`) or
    * emitted as constants stay put and are excluded.
    */
    function contentDerivedFields(adapter: ToolCommandAdapter): string[] {
    const render = (marker: string): Record<string, unknown> => {
    const match = adapter.formatFile(contentWith(marker)).match(/^---\n([\s\S]*?)\n---/);
    return (parseYaml(match![1]) ?? {}) as Record<string, unknown>;
    };
    // Deliberately different in length and shape. Two same-shaped markers
    // would render identically for a field derived via length or a slice,
    // and such a field would then be silently dropped from every assertion.
    const left = render('AAA');
    const right = render('zz-BBB-9-longer');
    return Object.keys(left).filter(
    (key) => JSON.stringify(left[key]) !== JSON.stringify(right[key])
    );
    }
    it('covers every registered YAML adapter', () => {
    const baseline = contentWith('Baseline');
    expect(yamlAdapters.length).toBeGreaterThan(0);
    for (const adapter of yamlAdapters) {
    expect(adapter.formatFile(baseline), adapter.toolId).toMatch(/^---\n/);
    }
    for (const toolId of NON_YAML_ADAPTERS) {
    const adapter = CommandAdapterRegistry.get(toolId);
    expect(adapter, `${toolId} is excluded but not registered`).toBeDefined();
    expect(adapter!.formatFile(baseline), toolId).not.toMatch(/^---\n/);
    }
    });
    const roundTripCases: Array<[string, string]> = [
    ['plain text', 'Enter explore mode for thinking'],
    ['empty string', ''],
    ['colon and quotes', 'Explore mode: "thinking" & planning (e.g. feature: dark-mode)'],
    ['block literal |', '|'],
    ['block literal |-', '|-'],
    ['block literal |+', '|+'],
    ['block folded >', '>'],
    ['block folded >-', '>-'],
    ['block folded >+', '>+'],
    ['block with text', '| block text'],
    ['folded with text', '> folded text'],
    ['boolean true', 'true'],
    ['boolean false', 'false'],
    ['boolean yes', 'yes'],
    ['boolean no', 'no'],
    ['boolean on', 'on'],
    ['boolean off', 'off'],
    ['null string', 'null'],
    ['tilde null', '~'],
    ['integer', '123'],
    ['zero', '0'],
    ['negative int', '-10'],
    ['float', '1.23'],
    ['scientific notation', '1e5'],
    ['hex integer', '0x12'],
    ['octal integer', '077'],
    ['binary integer', '0b101'],
    ['infinity', '.inf'],
    ['nan', '.nan'],
    ['special characters', '# comment: [a, b] {c: d} - item ? key *ref &anc !tag @at `cmd`'],
    ['leading space', ' leading'],
    ['trailing space', 'trailing '],
    ['multiple spaces', ' '],
    // Without these the matrix drives no control character at all, so the
    // escaping this suite exists to prove gets no adapter-level coverage —
    // and the raw-CR assertion below can never fail.
    ['carriage return', 'line 1\rline 2'],
    ['line feed', 'line 1\nline 2'],
    ['nul', 'a\x00b'],
    ['escape', 'ansi\x1b[0m'],
    ['delete', 'a\x7fb'],
    ['next line', 'a\x85b'],
    ];
    for (const adapter of yamlAdapters) {
    describe(`${adapter.toolId} adapter table-driven round-trip`, () => {
    for (const [label, testVal] of roundTripCases) {
    it(`preserves every string field and its type for ${label}`, () => {
    // Every string field carries the hostile value, not just
    // description: a field an adapter forgot to escape is only caught
    // if the matrix actually drives that field.
    const content: CommandContent = {
    id: 'explore',
    name: testVal,
    description: testVal,
    category: testVal,
    tags: [testVal, 'explore'],
    body: 'Body text',
    };
    const fileContent = adapter.formatFile(content);
    const frontmatterMatch = fileContent.match(/^---\n([\s\S]*?)\n---/);
    expect(frontmatterMatch).not.toBeNull();
    const frontmatter = frontmatterMatch![1];
    // A raw CR survives the parser but corrupts the file for anything
    // that splits on lines, so round-tripping alone would not catch it.
    expect(frontmatter, 'raw carriage return in frontmatter').not.toContain('\r');
    let parsed: Record<string, unknown> | undefined;
    expect(() => {
    parsed = parseYaml(frontmatter);
    }).not.toThrow();
    // Adapters emit different field subsets, and some derive name/id
    // from the command id rather than from the content. Identify the
  • I reran that adapter suite at 45cca5db: all 1,068 adapter tests passed.

This PR correctly identified the live colon-bearing-name failure and proposed the right shared helper. The merged work incorporated that diagnosis, covered these exact 4 adapters, and expanded it to the full adapter registry plus control-character cases. Thank you @seattled23 and Sōren Vale for the precise reproduction and focused test design.

@clay-good clay-good closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants