Skip to content

Commit fddc741

Browse files
committed
test(plugin): add collision guardrails for reserved slash commands (#1288)
Add automated detection of command name collisions between plugin commands and Claude Code built-in slash commands to prevent regressions. - Add validate-commands.ts with reserved denylist (33 commands), legacy allowlist, and namespace validation - Add 26 tests covering denylist, extraction, collision, and namespace - Add validate:commands script to package.json - Add plugin-validate-commands CI job to dev.yml
1 parent 30cb849 commit fddc741

4 files changed

Lines changed: 563 additions & 0 deletions

File tree

.github/workflows/dev.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ jobs:
195195
- name: Test with coverage
196196
run: yarn workspace codingbuddy-claude-plugin test:coverage
197197

198+
plugin-validate-commands:
199+
needs: install-dependencies
200+
runs-on: ubuntu-latest
201+
timeout-minutes: 15
202+
steps:
203+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
204+
- uses: ./.github/actions/setup
205+
206+
- name: Validate commands (collision guardrails)
207+
run: yarn workspace codingbuddy-claude-plugin validate:commands
208+
198209
plugin-build:
199210
needs: install-dependencies
200211
runs-on: ubuntu-latest

packages/claude-code-plugin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"typecheck": "tsc --noEmit",
4545
"circular": "madge --circular --extensions ts src/",
4646
"format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.ts\"",
47+
"validate:commands": "npx tsx scripts/validate-commands.ts",
4748
"test": "vitest run",
4849
"test:hooks": "python3 -m pytest tests/ -v --tb=short",
4950
"test:all": "vitest run && python3 -m pytest tests/ -v --tb=short",
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* Tests for validate-commands script
3+
*
4+
* Verifies collision guardrails for reserved slash commands:
5+
* 1. Reserved denylist includes known Claude Code commands
6+
* 2. Command extraction from commands/ directory
7+
* 3. Legacy allowlist prevents false positives
8+
* 4. Forbidden bare commands trigger failure
9+
* 5. Namespaced commands pass validation
10+
* 6. Collision detection works correctly
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14+
import * as fs from 'fs';
15+
import * as path from 'path';
16+
17+
import {
18+
RESERVED_COMMANDS,
19+
LEGACY_ALLOWLIST,
20+
extractCommandsFromDirectory,
21+
getBaseCommandName,
22+
isNamespaced,
23+
isReservedCommand,
24+
validateCommands,
25+
} from './validate-commands';
26+
27+
// ============================================================================
28+
// Reserved Denylist
29+
// ============================================================================
30+
31+
describe('reserved command denylist', () => {
32+
it('includes known Claude Code built-in commands', () => {
33+
const knownBuiltins = [
34+
'help',
35+
'clear',
36+
'exit',
37+
'memory',
38+
'status',
39+
'doctor',
40+
'model',
41+
'vim',
42+
'review',
43+
'config',
44+
'init',
45+
'mcp',
46+
'login',
47+
'logout',
48+
'cost',
49+
'compact',
50+
'permissions',
51+
'listen',
52+
'bug',
53+
'terminal-setup',
54+
];
55+
56+
for (const cmd of knownBuiltins) {
57+
expect(RESERVED_COMMANDS.has(cmd)).toBe(true);
58+
}
59+
});
60+
61+
it('is a non-empty set', () => {
62+
expect(RESERVED_COMMANDS.size).toBeGreaterThan(20);
63+
});
64+
65+
it('does not include plugin-specific commands', () => {
66+
expect(RESERVED_COMMANDS.has('plan')).toBe(false);
67+
expect(RESERVED_COMMANDS.has('act')).toBe(false);
68+
expect(RESERVED_COMMANDS.has('eval')).toBe(false);
69+
expect(RESERVED_COMMANDS.has('buddy')).toBe(false);
70+
});
71+
});
72+
73+
// ============================================================================
74+
// Legacy Allowlist
75+
// ============================================================================
76+
77+
describe('legacy allowlist', () => {
78+
it('contains current bare commands', () => {
79+
const expected = ['plan', 'act', 'eval', 'auto', 'buddy', 'checklist'];
80+
for (const cmd of expected) {
81+
expect(LEGACY_ALLOWLIST.has(cmd)).toBe(true);
82+
}
83+
});
84+
85+
it('does not overlap with reserved commands', () => {
86+
for (const cmd of LEGACY_ALLOWLIST) {
87+
expect(RESERVED_COMMANDS.has(cmd)).toBe(false);
88+
}
89+
});
90+
});
91+
92+
// ============================================================================
93+
// Command Extraction
94+
// ============================================================================
95+
96+
describe('extractCommandsFromDirectory', () => {
97+
const tmpDir = path.join(__dirname, '..', '__test_commands_tmp__');
98+
99+
beforeEach(() => {
100+
fs.mkdirSync(tmpDir, { recursive: true });
101+
});
102+
103+
afterEach(() => {
104+
fs.rmSync(tmpDir, { recursive: true, force: true });
105+
});
106+
107+
it('extracts command names from .md files', () => {
108+
fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan');
109+
fs.writeFileSync(path.join(tmpDir, 'act.md'), '# Act');
110+
111+
const commands = extractCommandsFromDirectory(tmpDir);
112+
expect(commands).toContain('plan');
113+
expect(commands).toContain('act');
114+
expect(commands).toHaveLength(2);
115+
});
116+
117+
it('ignores non-.md files', () => {
118+
fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan');
119+
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'notes');
120+
fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}');
121+
122+
const commands = extractCommandsFromDirectory(tmpDir);
123+
expect(commands).toEqual(['plan']);
124+
});
125+
126+
it('returns empty array for non-existent directory', () => {
127+
const commands = extractCommandsFromDirectory('/nonexistent/path');
128+
expect(commands).toEqual([]);
129+
});
130+
131+
it('returns empty array for empty directory', () => {
132+
const commands = extractCommandsFromDirectory(tmpDir);
133+
expect(commands).toEqual([]);
134+
});
135+
136+
it('detects the real commands/ directory', () => {
137+
const realCommandsDir = path.resolve(__dirname, '..', 'commands');
138+
const commands = extractCommandsFromDirectory(realCommandsDir);
139+
140+
expect(commands).toContain('plan');
141+
expect(commands).toContain('act');
142+
expect(commands).toContain('eval');
143+
expect(commands).toContain('auto');
144+
expect(commands).toContain('buddy');
145+
expect(commands).toContain('checklist');
146+
expect(commands.length).toBeGreaterThanOrEqual(6);
147+
});
148+
});
149+
150+
// ============================================================================
151+
// Namespace Helpers
152+
// ============================================================================
153+
154+
describe('getBaseCommandName', () => {
155+
it('strips namespace prefix', () => {
156+
expect(getBaseCommandName('codingbuddy:plan')).toBe('plan');
157+
});
158+
159+
it('returns bare name as-is', () => {
160+
expect(getBaseCommandName('plan')).toBe('plan');
161+
});
162+
163+
it('handles nested colons', () => {
164+
expect(getBaseCommandName('codingbuddy:sub:cmd')).toBe('sub:cmd');
165+
});
166+
});
167+
168+
describe('isNamespaced', () => {
169+
it('returns true for namespaced commands', () => {
170+
expect(isNamespaced('codingbuddy:plan')).toBe(true);
171+
});
172+
173+
it('returns false for bare commands', () => {
174+
expect(isNamespaced('plan')).toBe(false);
175+
});
176+
177+
it('returns false for other namespaces', () => {
178+
expect(isNamespaced('other:plan')).toBe(false);
179+
});
180+
});
181+
182+
// ============================================================================
183+
// Collision Detection
184+
// ============================================================================
185+
186+
describe('isReservedCommand', () => {
187+
it('detects reserved bare commands', () => {
188+
expect(isReservedCommand('help')).toBe(true);
189+
expect(isReservedCommand('mcp')).toBe(true);
190+
expect(isReservedCommand('config')).toBe(true);
191+
});
192+
193+
it('detects reserved commands even with namespace', () => {
194+
expect(isReservedCommand('codingbuddy:help')).toBe(true);
195+
expect(isReservedCommand('codingbuddy:mcp')).toBe(true);
196+
});
197+
198+
it('returns false for non-reserved commands', () => {
199+
expect(isReservedCommand('plan')).toBe(false);
200+
expect(isReservedCommand('buddy')).toBe(false);
201+
expect(isReservedCommand('codingbuddy:plan')).toBe(false);
202+
});
203+
});
204+
205+
// ============================================================================
206+
// Full Validation
207+
// ============================================================================
208+
209+
describe('validateCommands', () => {
210+
const tmpDir = path.join(__dirname, '..', '__test_validate_tmp__');
211+
212+
beforeEach(() => {
213+
fs.mkdirSync(tmpDir, { recursive: true });
214+
});
215+
216+
afterEach(() => {
217+
fs.rmSync(tmpDir, { recursive: true, force: true });
218+
});
219+
220+
it('passes with current legacy commands', () => {
221+
for (const cmd of LEGACY_ALLOWLIST) {
222+
fs.writeFileSync(path.join(tmpDir, `${cmd}.md`), `# ${cmd}`);
223+
}
224+
225+
const result = validateCommands(tmpDir);
226+
expect(result.valid).toBe(true);
227+
expect(result.collisions).toHaveLength(0);
228+
expect(result.namespaceViolations).toHaveLength(0);
229+
});
230+
231+
it('fails when a reserved command is introduced', () => {
232+
fs.writeFileSync(path.join(tmpDir, 'help.md'), '# Help');
233+
234+
const result = validateCommands(tmpDir);
235+
expect(result.valid).toBe(false);
236+
expect(result.collisions).toContain('help');
237+
});
238+
239+
it('fails when a bare command is not in legacy allowlist', () => {
240+
fs.writeFileSync(path.join(tmpDir, 'my-new-command.md'), '# New');
241+
242+
const result = validateCommands(tmpDir);
243+
expect(result.valid).toBe(false);
244+
expect(result.namespaceViolations).toContain('my-new-command');
245+
});
246+
247+
it('reports both collision and namespace violation for reserved bare command', () => {
248+
fs.writeFileSync(path.join(tmpDir, 'mcp.md'), '# MCP');
249+
250+
const result = validateCommands(tmpDir);
251+
expect(result.valid).toBe(false);
252+
expect(result.collisions).toContain('mcp');
253+
expect(result.namespaceViolations).toContain('mcp');
254+
});
255+
256+
it('handles empty commands directory', () => {
257+
const result = validateCommands(tmpDir);
258+
expect(result.valid).toBe(true);
259+
expect(result.commands).toHaveLength(0);
260+
});
261+
262+
it('handles non-existent directory', () => {
263+
const result = validateCommands('/nonexistent/path');
264+
expect(result.valid).toBe(true);
265+
expect(result.commands).toHaveLength(0);
266+
});
267+
268+
it('validates the real commands/ directory passes', () => {
269+
const realCommandsDir = path.resolve(__dirname, '..', 'commands');
270+
const result = validateCommands(realCommandsDir);
271+
expect(result.valid).toBe(true);
272+
expect(result.collisions).toHaveLength(0);
273+
expect(result.namespaceViolations).toHaveLength(0);
274+
});
275+
});

0 commit comments

Comments
 (0)