Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ jobs:
- name: Test with coverage
run: yarn workspace codingbuddy-claude-plugin test:coverage

plugin-validate-commands:
needs: install-dependencies
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup

- name: Validate commands (collision guardrails)
run: yarn workspace codingbuddy-claude-plugin validate:commands

plugin-build:
needs: install-dependencies
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions packages/claude-code-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"typecheck": "tsc --noEmit",
"circular": "madge --circular --extensions ts src/",
"format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.ts\"",
"validate:commands": "npx tsx scripts/validate-commands.ts",
"test": "vitest run",
"test:hooks": "python3 -m pytest tests/ -v --tb=short",
"test:all": "vitest run && python3 -m pytest tests/ -v --tb=short",
Expand Down
275 changes: 275 additions & 0 deletions packages/claude-code-plugin/scripts/validate-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* Tests for validate-commands script
*
* Verifies collision guardrails for reserved slash commands:
* 1. Reserved denylist includes known Claude Code commands
* 2. Command extraction from commands/ directory
* 3. Legacy allowlist prevents false positives
* 4. Forbidden bare commands trigger failure
* 5. Namespaced commands pass validation
* 6. Collision detection works correctly
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';

import {
RESERVED_COMMANDS,
LEGACY_ALLOWLIST,
extractCommandsFromDirectory,
getBaseCommandName,
isNamespaced,
isReservedCommand,
validateCommands,
} from './validate-commands';

// ============================================================================
// Reserved Denylist
// ============================================================================

describe('reserved command denylist', () => {
it('includes known Claude Code built-in commands', () => {
const knownBuiltins = [
'help',
'clear',
'exit',
'memory',
'status',
'doctor',
'model',
'vim',
'review',
'config',
'init',
'mcp',
'login',
'logout',
'cost',
'compact',
'permissions',
'listen',
'bug',
'terminal-setup',
];

for (const cmd of knownBuiltins) {
expect(RESERVED_COMMANDS.has(cmd)).toBe(true);
}
});

it('is a non-empty set', () => {
expect(RESERVED_COMMANDS.size).toBeGreaterThan(20);
});

it('does not include plugin-specific commands', () => {
expect(RESERVED_COMMANDS.has('plan')).toBe(false);
expect(RESERVED_COMMANDS.has('act')).toBe(false);
expect(RESERVED_COMMANDS.has('eval')).toBe(false);
expect(RESERVED_COMMANDS.has('buddy')).toBe(false);
});
});

// ============================================================================
// Legacy Allowlist
// ============================================================================

describe('legacy allowlist', () => {
it('contains current bare commands', () => {
const expected = ['plan', 'act', 'eval', 'auto', 'buddy', 'checklist'];
for (const cmd of expected) {
expect(LEGACY_ALLOWLIST.has(cmd)).toBe(true);
}
});

it('does not overlap with reserved commands', () => {
for (const cmd of LEGACY_ALLOWLIST) {
expect(RESERVED_COMMANDS.has(cmd)).toBe(false);
}
});
});

// ============================================================================
// Command Extraction
// ============================================================================

describe('extractCommandsFromDirectory', () => {
const tmpDir = path.join(__dirname, '..', '__test_commands_tmp__');

beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('extracts command names from .md files', () => {
fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan');
fs.writeFileSync(path.join(tmpDir, 'act.md'), '# Act');

const commands = extractCommandsFromDirectory(tmpDir);
expect(commands).toContain('plan');
expect(commands).toContain('act');
expect(commands).toHaveLength(2);
});

it('ignores non-.md files', () => {
fs.writeFileSync(path.join(tmpDir, 'plan.md'), '# Plan');
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'notes');
fs.writeFileSync(path.join(tmpDir, 'config.json'), '{}');

const commands = extractCommandsFromDirectory(tmpDir);
expect(commands).toEqual(['plan']);
});

it('returns empty array for non-existent directory', () => {
const commands = extractCommandsFromDirectory('/nonexistent/path');
expect(commands).toEqual([]);
});

it('returns empty array for empty directory', () => {
const commands = extractCommandsFromDirectory(tmpDir);
expect(commands).toEqual([]);
});

it('detects the real commands/ directory', () => {
const realCommandsDir = path.resolve(__dirname, '..', 'commands');
const commands = extractCommandsFromDirectory(realCommandsDir);

expect(commands).toContain('plan');
expect(commands).toContain('act');
expect(commands).toContain('eval');
expect(commands).toContain('auto');
expect(commands).toContain('buddy');
expect(commands).toContain('checklist');
expect(commands.length).toBeGreaterThanOrEqual(6);
});
});

// ============================================================================
// Namespace Helpers
// ============================================================================

describe('getBaseCommandName', () => {
it('strips namespace prefix', () => {
expect(getBaseCommandName('codingbuddy:plan')).toBe('plan');
});

it('returns bare name as-is', () => {
expect(getBaseCommandName('plan')).toBe('plan');
});

it('handles nested colons', () => {
expect(getBaseCommandName('codingbuddy:sub:cmd')).toBe('sub:cmd');
});
});

describe('isNamespaced', () => {
it('returns true for namespaced commands', () => {
expect(isNamespaced('codingbuddy:plan')).toBe(true);
});

it('returns false for bare commands', () => {
expect(isNamespaced('plan')).toBe(false);
});

it('returns false for other namespaces', () => {
expect(isNamespaced('other:plan')).toBe(false);
});
});

// ============================================================================
// Collision Detection
// ============================================================================

describe('isReservedCommand', () => {
it('detects reserved bare commands', () => {
expect(isReservedCommand('help')).toBe(true);
expect(isReservedCommand('mcp')).toBe(true);
expect(isReservedCommand('config')).toBe(true);
});

it('detects reserved commands even with namespace', () => {
expect(isReservedCommand('codingbuddy:help')).toBe(true);
expect(isReservedCommand('codingbuddy:mcp')).toBe(true);
});

it('returns false for non-reserved commands', () => {
expect(isReservedCommand('plan')).toBe(false);
expect(isReservedCommand('buddy')).toBe(false);
expect(isReservedCommand('codingbuddy:plan')).toBe(false);
});
});

// ============================================================================
// Full Validation
// ============================================================================

describe('validateCommands', () => {
const tmpDir = path.join(__dirname, '..', '__test_validate_tmp__');

beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('passes with current legacy commands', () => {
for (const cmd of LEGACY_ALLOWLIST) {
fs.writeFileSync(path.join(tmpDir, `${cmd}.md`), `# ${cmd}`);
}

const result = validateCommands(tmpDir);
expect(result.valid).toBe(true);
expect(result.collisions).toHaveLength(0);
expect(result.namespaceViolations).toHaveLength(0);
});

it('fails when a reserved command is introduced', () => {
fs.writeFileSync(path.join(tmpDir, 'help.md'), '# Help');

const result = validateCommands(tmpDir);
expect(result.valid).toBe(false);
expect(result.collisions).toContain('help');
});

it('fails when a bare command is not in legacy allowlist', () => {
fs.writeFileSync(path.join(tmpDir, 'my-new-command.md'), '# New');

const result = validateCommands(tmpDir);
expect(result.valid).toBe(false);
expect(result.namespaceViolations).toContain('my-new-command');
});

it('reports both collision and namespace violation for reserved bare command', () => {
fs.writeFileSync(path.join(tmpDir, 'mcp.md'), '# MCP');

const result = validateCommands(tmpDir);
expect(result.valid).toBe(false);
expect(result.collisions).toContain('mcp');
expect(result.namespaceViolations).toContain('mcp');
});

it('handles empty commands directory', () => {
const result = validateCommands(tmpDir);
expect(result.valid).toBe(true);
expect(result.commands).toHaveLength(0);
});

it('handles non-existent directory', () => {
const result = validateCommands('/nonexistent/path');
expect(result.valid).toBe(true);
expect(result.commands).toHaveLength(0);
});

it('validates the real commands/ directory passes', () => {
const realCommandsDir = path.resolve(__dirname, '..', 'commands');
const result = validateCommands(realCommandsDir);
expect(result.valid).toBe(true);
expect(result.collisions).toHaveLength(0);
expect(result.namespaceViolations).toHaveLength(0);
});
});
Loading
Loading