From 61e201fe30333809ae2761ed8d1cba001b89480f Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 5 Apr 2026 08:20:42 +0900 Subject: [PATCH] improve(plugin): prove end-to-end /codingbuddy:* command migration (#1343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add integration tests proving the namespace-manifest runtime path: - plugin.json name → commands/ filenames → manifest → validator consistency - Bidirectional manifest ↔ commands/ coverage - Cross-artifact namespace coherence check Clean up validator legacy terminology: - Rename LEGACY_ALLOWLIST → KNOWN_BARE_COMMANDS (deprecated alias kept) - Update comments and CLI output to reflect canonical model Align migration guide messaging: - Status column: "migration target" → "complete" - Timeline section reflects proven implementation state --- .../docs/migration-guide.md | 22 ++-- .../claude-code-plugin/scripts/build.spec.ts | 113 +++++++++++++++++- .../scripts/validate-commands.spec.ts | 19 +-- .../scripts/validate-commands.ts | 26 ++-- 4 files changed, 151 insertions(+), 29 deletions(-) diff --git a/packages/claude-code-plugin/docs/migration-guide.md b/packages/claude-code-plugin/docs/migration-guide.md index 7e770c29..8f03502b 100644 --- a/packages/claude-code-plugin/docs/migration-guide.md +++ b/packages/claude-code-plugin/docs/migration-guide.md @@ -32,20 +32,20 @@ Keywords also support localized variants: Korean (`계획`/`실행`/`평가`/` If you prefer slash commands, use the namespaced form: -| Legacy (bare) | Namespaced (new) | Status | -| -------------- | -------------------------- | ---------------- | -| `/plan` | `/codingbuddy:plan` | migration target | -| `/act` | `/codingbuddy:act` | migration target | -| `/eval` | `/codingbuddy:eval` | migration target | -| `/auto` | `/codingbuddy:auto` | migration target | -| `/buddy` | `/codingbuddy:buddy` | migration target | -| `/checklist` | `/codingbuddy:checklist` | migration target | +| Legacy (bare) | Namespaced (canonical) | Status | +| -------------- | -------------------------- | -------- | +| `/plan` | `/codingbuddy:plan` | complete | +| `/act` | `/codingbuddy:act` | complete | +| `/eval` | `/codingbuddy:eval` | complete | +| `/auto` | `/codingbuddy:auto` | complete | +| `/buddy` | `/codingbuddy:buddy` | complete | +| `/checklist` | `/codingbuddy:checklist` | complete | ## Timeline -1. **Now**: Both bare and namespaced commands work. Keywords are the recommended entry point. -2. **Transition**: Bare commands are deprecated but functional. New commands use `codingbuddy:*` only. -3. **Future**: Once Claude Code fully supports `plugin:command` namespace resolution, bare aliases will be removed. +1. **Complete**: All commands are namespaced as `codingbuddy:*`. Claude Code resolves them via `plugin.json` name + `commands/` filenames. Keywords remain the recommended entry point. +2. **Bare aliases**: Legacy bare commands (`/plan`, `/act`, etc.) continue to work. New commands use `codingbuddy:*` only. +3. **Future**: Once Claude Code drops bare-alias resolution, legacy bare filenames may be removed. ## What Changed and Why diff --git a/packages/claude-code-plugin/scripts/build.spec.ts b/packages/claude-code-plugin/scripts/build.spec.ts index d29b6c92..a6250b7c 100644 --- a/packages/claude-code-plugin/scripts/build.spec.ts +++ b/packages/claude-code-plugin/scripts/build.spec.ts @@ -16,7 +16,12 @@ import { describe, it, expect, beforeAll } from 'vitest'; // Import utilities that are testable in isolation import { getErrorMessage, type BuildMode } from '../src/utils'; import { buildNamespaceManifest, type NamespaceManifest } from './build'; -import { PLUGIN_NAMESPACE, NAMESPACE_SEPARATOR, LEGACY_ALLOWLIST } from './validate-commands'; +import { + PLUGIN_NAMESPACE, + NAMESPACE_SEPARATOR, + KNOWN_BARE_COMMANDS, + validateCommands, +} from './validate-commands'; describe('build script orchestration', () => { // ============================================================================ @@ -157,7 +162,7 @@ describe('build script orchestration', () => { it('includes all current command files', () => { const bareNames = manifest.commands.map(c => c.bare); - for (const cmd of LEGACY_ALLOWLIST) { + for (const cmd of KNOWN_BARE_COMMANDS) { expect(bareNames).toContain(cmd); } }); @@ -269,3 +274,107 @@ describe('packaging integration', () => { expect(manifest.commands.length).toBeGreaterThanOrEqual(6); }); }); + +// ============================================================================ +// End-to-End Namespace Consumption — proves the runtime path is consistent +// ============================================================================ +describe('end-to-end namespace consumption', () => { + const pluginRoot = path.resolve(__dirname, '..'); + const commandsDir = path.join(pluginRoot, 'commands'); + const manifestPath = path.join(pluginRoot, 'namespace-manifest.json'); + const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json'); + const pkgPath = path.join(pluginRoot, 'package.json'); + + /** + * Claude Code resolves plugin commands as `{plugin.json.name}:{command-filename}`. + * This test suite proves the full chain: + * plugin.json.name → commands/*.md filenames → namespace-manifest.json → validator + * are all consistent and produce the expected `codingbuddy:*` namespace. + */ + + // --- Artifact existence --- + + it('all required artifacts exist on disk', () => { + expect(fs.existsSync(pluginJsonPath)).toBe(true); + expect(fs.existsSync(commandsDir)).toBe(true); + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(pkgPath)).toBe(true); + }); + + // --- plugin.json → namespace --- + + it('plugin.json name establishes the namespace prefix', () => { + const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + expect(pluginJson.name).toBe(PLUGIN_NAMESPACE); + // Claude Code resolves: /{plugin.name}:{command} + expect(`${pluginJson.name}${NAMESPACE_SEPARATOR}`).toBe(`${PLUGIN_NAMESPACE}:`); + }); + + // --- commands/ ↔ manifest bidirectional consistency --- + + it('every commands/*.md file has a corresponding manifest entry', () => { + const files: string[] = fs.readdirSync(commandsDir).filter((f: string) => f.endsWith('.md')); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestBareNames: string[] = manifest.commands.map((c: { bare: string }) => c.bare); + + for (const file of files) { + const bare = path.basename(file, '.md'); + expect(manifestBareNames).toContain(bare); + } + }); + + it('every manifest entry has a corresponding commands/*.md file', () => { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + for (const cmd of manifest.commands) { + const filePath = path.join(pluginRoot, cmd.file); + expect(fs.existsSync(filePath)).toBe(true); + } + }); + + it('manifest count matches commands/ file count exactly', () => { + const files: string[] = fs.readdirSync(commandsDir).filter((f: string) => f.endsWith('.md')); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + expect(manifest.commands.length).toBe(files.length); + }); + + // --- Namespaced form correctness --- + + it('manifest namespaced fields match plugin.json name + bare name', () => { + const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + for (const cmd of manifest.commands) { + const expected = `${pluginJson.name}${NAMESPACE_SEPARATOR}${cmd.bare}`; + expect(cmd.namespaced).toBe(expected); + } + }); + + // --- Validator agreement --- + + it('validator passes for all commands in the manifest', () => { + const result = validateCommands(commandsDir); + expect(result.valid).toBe(true); + expect(result.collisions).toHaveLength(0); + expect(result.namespaceViolations).toHaveLength(0); + }); + + // --- Package distribution --- + + it('package.json files array ships all namespace artifacts', () => { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + expect(pkg.files).toContain('namespace-manifest.json'); + expect(pkg.files).toContain('commands/'); + expect(pkg.files).toContain('.claude-plugin'); + }); + + // --- Cross-artifact namespace coherence --- + + it('plugin.json name, manifest pluginName, and PLUGIN_NAMESPACE constant all agree', () => { + const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + expect(pluginJson.name).toBe(PLUGIN_NAMESPACE); + expect(manifest.pluginName).toBe(PLUGIN_NAMESPACE); + expect(manifest.namespace).toBe(`${PLUGIN_NAMESPACE}${NAMESPACE_SEPARATOR}`); + }); +}); diff --git a/packages/claude-code-plugin/scripts/validate-commands.spec.ts b/packages/claude-code-plugin/scripts/validate-commands.spec.ts index abaeda0b..d427a0a0 100644 --- a/packages/claude-code-plugin/scripts/validate-commands.spec.ts +++ b/packages/claude-code-plugin/scripts/validate-commands.spec.ts @@ -16,6 +16,7 @@ import * as path from 'path'; import { RESERVED_COMMANDS, + KNOWN_BARE_COMMANDS, LEGACY_ALLOWLIST, extractCommandsFromDirectory, getBaseCommandName, @@ -71,22 +72,26 @@ describe('reserved command denylist', () => { }); // ============================================================================ -// Legacy Allowlist +// Known Bare Commands // ============================================================================ -describe('legacy allowlist', () => { - it('contains current bare commands', () => { +describe('known bare commands', () => { + it('contains current bare command filenames', () => { const expected = ['plan', 'act', 'eval', 'auto', 'buddy', 'checklist']; for (const cmd of expected) { - expect(LEGACY_ALLOWLIST.has(cmd)).toBe(true); + expect(KNOWN_BARE_COMMANDS.has(cmd)).toBe(true); } }); it('does not overlap with reserved commands', () => { - for (const cmd of LEGACY_ALLOWLIST) { + for (const cmd of KNOWN_BARE_COMMANDS) { expect(RESERVED_COMMANDS.has(cmd)).toBe(false); } }); + + it('LEGACY_ALLOWLIST alias points to the same set', () => { + expect(LEGACY_ALLOWLIST).toBe(KNOWN_BARE_COMMANDS); + }); }); // ============================================================================ @@ -218,7 +223,7 @@ describe('validateCommands', () => { }); it('passes with current legacy commands', () => { - for (const cmd of LEGACY_ALLOWLIST) { + for (const cmd of KNOWN_BARE_COMMANDS) { fs.writeFileSync(path.join(tmpDir, `${cmd}.md`), `# ${cmd}`); } @@ -236,7 +241,7 @@ describe('validateCommands', () => { expect(result.collisions).toContain('help'); }); - it('fails when a bare command is not in legacy allowlist', () => { + it('fails when a bare command is not in known bare commands', () => { fs.writeFileSync(path.join(tmpDir, 'my-new-command.md'), '# New'); const result = validateCommands(tmpDir); diff --git a/packages/claude-code-plugin/scripts/validate-commands.ts b/packages/claude-code-plugin/scripts/validate-commands.ts index 6b131238..611900cf 100644 --- a/packages/claude-code-plugin/scripts/validate-commands.ts +++ b/packages/claude-code-plugin/scripts/validate-commands.ts @@ -84,15 +84,20 @@ export const RESERVED_COMMANDS: ReadonlySet = new Set([ ]); // ============================================================================ -// Legacy Allowlist +// Known Bare Commands // ============================================================================ /** - * Known legacy bare commands that are intentionally kept without namespace prefix. - * These existed before the codingbuddy:* namespace convention. + * Bare command filenames shipped in commands/. + * + * Claude Code resolves these as `{plugin.json.name}:{filename}` at runtime, + * so the files are stored without a namespace prefix. * New commands MUST use the codingbuddy:* namespace. + * + * @deprecated Use `KNOWN_BARE_COMMANDS`. The `LEGACY_ALLOWLIST` alias is + * retained only for backward compatibility with existing test imports. */ -export const LEGACY_ALLOWLIST: ReadonlySet = new Set([ +export const KNOWN_BARE_COMMANDS: ReadonlySet = new Set([ 'plan', 'act', 'eval', @@ -101,6 +106,9 @@ export const LEGACY_ALLOWLIST: ReadonlySet = new Set([ 'checklist', ]); +/** @deprecated Alias — prefer {@link KNOWN_BARE_COMMANDS}. */ +export const LEGACY_ALLOWLIST = KNOWN_BARE_COMMANDS; + // ============================================================================ // Plugin Namespace // ============================================================================ @@ -182,7 +190,7 @@ export function isReservedCommand(command: string): boolean { * * Rules: * 1. No command may collide with a reserved Claude Code built-in - * 2. Bare (non-namespaced) commands must be in the legacy allowlist + * 2. Bare (non-namespaced) filenames must be in KNOWN_BARE_COMMANDS * 3. New commands must use the codingbuddy:* namespace */ export function validateCommands(commandsDir: string): ValidationResult { @@ -205,8 +213,8 @@ export function validateCommands(commandsDir: string): ValidationResult { result.valid = false; } - // Check namespace compliance: bare commands must be in legacy allowlist - if (!isNamespaced(cmd) && !LEGACY_ALLOWLIST.has(cmd)) { + // Check namespace compliance: bare filenames must be in KNOWN_BARE_COMMANDS + if (!isNamespaced(cmd) && !KNOWN_BARE_COMMANDS.has(cmd)) { result.namespaceViolations.push(cmd); result.valid = false; } @@ -233,7 +241,7 @@ function main(): void { console.log(`Commands found: ${result.commands.join(', ') || '(none)'}`); console.log(`Reserved denylist size: ${RESERVED_COMMANDS.size}`); - console.log(`Legacy allowlist: ${[...LEGACY_ALLOWLIST].join(', ')}`); + console.log(`Known bare commands: ${[...KNOWN_BARE_COMMANDS].join(', ')}`); console.log(''); if (result.collisions.length > 0) { @@ -248,7 +256,7 @@ function main(): void { console.error('❌ NAMESPACE VIOLATION:'); for (const cmd of result.namespaceViolations) { console.error( - ` "${cmd}" is a bare command not in the legacy allowlist. Use "${PLUGIN_NAMESPACE}:${cmd}" instead.`, + ` "${cmd}" is not a known bare command. Use "${PLUGIN_NAMESPACE}:${cmd}" instead.`, ); } console.error('');