From 51e4485f580488ee838be02e69372d9a2271ed6f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 10 Oct 2026 19:28:09 -0400 Subject: [PATCH] fix(validation): read top-level command descriptions Signed-off-by: Rudy Celekli --- scripts/validate-commands-test.js | 30 ++++++++++++++++++++++++++++++ scripts/validate-commands.js | 22 ++++++---------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/scripts/validate-commands-test.js b/scripts/validate-commands-test.js index 8c65c867..6db8909f 100644 --- a/scripts/validate-commands-test.js +++ b/scripts/validate-commands-test.js @@ -214,3 +214,33 @@ test('the TOML directories are not put through the YAML rules', () => { assert.equal(result.status, 0, result.stdout); assert.doesNotMatch(result.stdout, /\.gemini.*unquoted value/); }); + + +test('nested metadata descriptions cannot hide command description drift', () => { + const root = makeSandbox(); + writeClaudeCommand(root, 'review', 'metadata:\n description: Review a change\ndescription: Inspect a change'); + writeTomlCommand(root, path.join('.gemini', 'commands'), 'review', 'description = "Review a change"'); + writeTomlCommand(root, 'commands', 'review', 'description = "Review a change"'); + const result = run(root); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /Inspect a change/); +}); + +test('matching top-level descriptions pass despite different nested metadata', () => { + const root = makeSandbox(); + writeClaudeCommand(root, 'review', 'metadata:\n description: Internal metadata\ndescription: Review a change'); + writeTomlCommand(root, path.join('.gemini', 'commands'), 'review', 'description = "Review a change"'); + writeTomlCommand(root, 'commands', 'review', 'description = "Review a change"'); + const result = run(root); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +test('a nested description does not supply a missing command description', () => { + const root = makeSandbox(); + writeClaudeCommand(root, 'review', 'metadata:\n description: Review a change'); + writeTomlCommand(root, path.join('.gemini', 'commands'), 'review', 'description = "Review a change"'); + writeTomlCommand(root, 'commands', 'review', 'description = "Review a change"'); + const result = run(root); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /missing or malformed description/); +}); diff --git a/scripts/validate-commands.js b/scripts/validate-commands.js index f26736bb..ebd0a1e0 100644 --- a/scripts/validate-commands.js +++ b/scripts/validate-commands.js @@ -25,12 +25,11 @@ const fs = require('fs'); const path = require('path'); // The same frontmatter-validity rules validate-skills applies to SKILL.md. -// `descriptionFromMd` below splits each line on its first colon, exactly as the -// skill reader used to, so a command whose frontmatter is not valid YAML passes -// every check here — and Claude Code parses that frontmatter when the command is -// loaded. The #494 thread verified all 25 SKILL.md files AND the command files by -// hand; this makes the second half a check too. -const { frontmatterYamlErrors } = require(path.join(__dirname, 'lib', 'skill-lint.js')); +// Description extraction shares the top-level frontmatter reader with skills. +// YAML validity is checked separately because Claude Code parses frontmatter +// when the command is loaded. The #494 thread verified all 25 SKILL.md files +// and the command files by hand; this makes the second half a check too. +const { frontmatterYamlErrors, parseFrontmatter } = require(path.join(__dirname, 'lib', 'skill-lint.js')); // ─── Config ─────────────────────────────────────────────────────────────────── @@ -55,16 +54,7 @@ const NAME_MAP_REVERSE = Object.fromEntries( function descriptionFromMd(filePath) { const content = fs.readFileSync(filePath, 'utf8'); - const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/); - if (!match) return null; - for (const line of match[1].split(/\r?\n/)) { - const colonIdx = line.indexOf(':'); - if (colonIdx === -1) continue; - if (line.slice(0, colonIdx).trim() === 'description') { - return line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, ''); - } - } - return null; + return parseFrontmatter(content)?.description ?? null; } function descriptionFromToml(filePath) {