From 492c99ac2489195cb14376d4c5cdd77462d787fd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Feb 2026 16:08:49 -0800 Subject: [PATCH] fix: 3 bugs fixed, stdin encoding hardened, 37 CI validator tests added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - utils.js: glob-to-regex conversion now escapes all regex special chars (+, ^, $, |, (), {}, [], \) before converting * and ? wildcards - validate-hooks.js: escape sequence processing order corrected — \\\\ now processed before \\n and \\t to prevent double-processing - 6 hooks: added process.stdin.setEncoding('utf8') to prevent multi-byte UTF-8 character corruption at chunk boundaries (check-console-log, post-edit-format, post-edit-typecheck, post-edit-console-warn, session-end, evaluate-session) New tests (37): - CI validator test suite (tests/ci/validators.test.js): - validate-agents: 9 tests (real project, frontmatter parsing, BOM/CRLF, colons in values, missing fields, non-md skip) - validate-hooks: 13 tests (real project, invalid JSON, invalid event types, missing fields, async/timeout validation, inline JS syntax, array commands, legacy format) - validate-skills: 6 tests (real project, missing SKILL.md, empty files, non-directory entries) - validate-commands: 5 tests (real project, empty files, non-md skip) - validate-rules: 4 tests (real project, empty files) Total test count: 228 (up from 191) --- scripts/ci/validate-hooks.js | 2 +- scripts/hooks/check-console-log.js | 1 + scripts/hooks/evaluate-session.js | 1 + scripts/hooks/post-edit-console-warn.js | 1 + scripts/hooks/post-edit-format.js | 1 + scripts/hooks/post-edit-typecheck.js | 1 + scripts/hooks/session-end.js | 1 + scripts/lib/utils.js | 4 +- tests/ci/validators.test.js | 515 ++++++++++++++++++++++++ tests/run-all.js | 3 +- 10 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 tests/ci/validators.test.js diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js index ddc08df1..ee82e134 100644 --- a/scripts/ci/validate-hooks.js +++ b/scripts/ci/validate-hooks.js @@ -42,7 +42,7 @@ function validateHookEntry(hook, label) { const nodeEMatch = hook.command.match(/^node -e "(.*)"$/s); if (nodeEMatch) { try { - new vm.Script(nodeEMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\\\/g, '\\')); + new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); } catch (syntaxErr) { console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); hasErrors = true; diff --git a/scripts/hooks/check-console-log.js b/scripts/hooks/check-console-log.js index 8bbf45ea..62d10242 100755 --- a/scripts/hooks/check-console-log.js +++ b/scripts/hooks/check-console-log.js @@ -28,6 +28,7 @@ const EXCLUDED_PATTERNS = [ const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < MAX_STDIN) { diff --git a/scripts/hooks/evaluate-session.js b/scripts/hooks/evaluate-session.js index 1aaa67db..4b9824f6 100644 --- a/scripts/hooks/evaluate-session.js +++ b/scripts/hooks/evaluate-session.js @@ -25,6 +25,7 @@ const { // Read hook input from stdin (Claude Code provides transcript_path via stdin JSON) const MAX_STDIN = 1024 * 1024; let stdinData = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (stdinData.length < MAX_STDIN) { diff --git a/scripts/hooks/post-edit-console-warn.js b/scripts/hooks/post-edit-console-warn.js index fbd91503..51b443a1 100644 --- a/scripts/hooks/post-edit-console-warn.js +++ b/scripts/hooks/post-edit-console-warn.js @@ -13,6 +13,7 @@ const { readFile } = require('../lib/utils'); const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < MAX_STDIN) { diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 0bee957a..14f882f9 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -12,6 +12,7 @@ const { execFileSync } = require('child_process'); const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < MAX_STDIN) { diff --git a/scripts/hooks/post-edit-typecheck.js b/scripts/hooks/post-edit-typecheck.js index 83dde5b2..a6913bc1 100644 --- a/scripts/hooks/post-edit-typecheck.js +++ b/scripts/hooks/post-edit-typecheck.js @@ -15,6 +15,7 @@ const path = require('path'); const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < MAX_STDIN) { diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index bb2133fe..40553e47 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -88,6 +88,7 @@ function extractSessionSummary(transcriptPath) { // Read hook input from stdin (Claude Code provides transcript_path via stdin JSON) const MAX_STDIN = 1024 * 1024; let stdinData = ''; +process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (stdinData.length < MAX_STDIN) { diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index b1da6331..b675a08b 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -150,8 +150,10 @@ function findFiles(dir, pattern, options = {}) { return results; } + // Escape all regex special characters, then convert glob wildcards. + // Order matters: escape specials first, then convert * and ? to regex equivalents. const regexPattern = pattern - .replace(/\./g, '\\.') + .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`); diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js new file mode 100644 index 00000000..4d94c3c2 --- /dev/null +++ b/tests/ci/validators.test.js @@ -0,0 +1,515 @@ +/** + * Tests for CI validator scripts + * + * Tests both success paths (against the real project) and error paths + * (against temporary fixture directories via wrapper scripts). + * + * Run with: node tests/ci/validators.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { execSync, execFileSync } = require('child_process'); + +const validatorsDir = path.join(__dirname, '..', '..', 'scripts', 'ci'); + +// Test helpers +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function createTestDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ci-validator-test-')); +} + +function cleanupTestDir(testDir) { + fs.rmSync(testDir, { recursive: true, force: true }); +} + +/** + * Run a validator script via a wrapper that overrides its directory constant. + * This allows testing error cases without modifying real project files. + * + * @param {string} validatorName - e.g., 'validate-agents' + * @param {string} dirConstant - the constant name to override (e.g., 'AGENTS_DIR') + * @param {string} overridePath - the temp directory to use + * @returns {{code: number, stdout: string, stderr: string}} + */ +function runValidatorWithDir(validatorName, dirConstant, overridePath) { + const validatorPath = path.join(validatorsDir, `${validatorName}.js`); + + // Read the validator source, replace the directory constant, and run as a wrapper + let source = fs.readFileSync(validatorPath, 'utf8'); + + // Remove the shebang line + source = source.replace(/^#!.*\n/, ''); + + // Replace the directory constant with our override path + const dirRegex = new RegExp(`const ${dirConstant} = .*?;`); + source = source.replace(dirRegex, `const ${dirConstant} = ${JSON.stringify(overridePath)};`); + + try { + const stdout = execFileSync('node', ['-e', source], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status || 1, + stdout: err.stdout || '', + stderr: err.stderr || '', + }; + } +} + +/** + * Run a validator script directly (tests real project) + */ +function runValidator(validatorName) { + const validatorPath = path.join(validatorsDir, `${validatorName}.js`); + try { + const stdout = execFileSync('node', [validatorPath], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status || 1, + stdout: err.stdout || '', + stderr: err.stderr || '', + }; + } +} + +function runTests() { + console.log('\n=== Testing CI Validators ===\n'); + + let passed = 0; + let failed = 0; + + // ========================================== + // validate-agents.js + // ========================================== + console.log('validate-agents.js:'); + + if (test('passes on real project agents', () => { + const result = runValidator('validate-agents'); + assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Validated'), 'Should output validation count'); + })) passed++; else failed++; + + if (test('fails on agent without frontmatter', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'bad-agent.md'), '# No frontmatter here\nJust content.'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should exit 1 for missing frontmatter'); + assert.ok(result.stderr.includes('Missing frontmatter'), 'Should report missing frontmatter'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on agent missing required model field', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'no-model.md'), '---\ntools: Read, Write\n---\n# Agent'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should exit 1 for missing model'); + assert.ok(result.stderr.includes('model'), 'Should report missing model field'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on agent missing required tools field', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'no-tools.md'), '---\nmodel: sonnet\n---\n# Agent'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should exit 1 for missing tools'); + assert.ok(result.stderr.includes('tools'), 'Should report missing tools field'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('passes on valid agent with all required fields', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'good-agent.md'), '---\nmodel: sonnet\ntools: Read, Write\n---\n# Agent'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should pass for valid agent'); + assert.ok(result.stdout.includes('Validated 1'), 'Should report 1 validated'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('handles frontmatter with BOM and CRLF', () => { + const testDir = createTestDir(); + const content = '\uFEFF---\r\nmodel: sonnet\r\ntools: Read, Write\r\n---\r\n# Agent'; + fs.writeFileSync(path.join(testDir, 'bom-agent.md'), content); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should handle BOM and CRLF'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('handles frontmatter with colons in values', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'colon-agent.md'), '---\nmodel: claude-sonnet-4-5-20250929\ntools: Read, Write, Bash\n---\n# Agent'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should handle colons in values'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('skips non-md files', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'readme.txt'), 'Not an agent'); + fs.writeFileSync(path.join(testDir, 'valid.md'), '---\nmodel: sonnet\ntools: Read\n---\n# Agent'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should only validate .md files'); + assert.ok(result.stdout.includes('Validated 1'), 'Should count only .md files'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('exits 0 when directory does not exist', () => { + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', '/nonexistent/dir'); + assert.strictEqual(result.code, 0, 'Should skip when no agents dir'); + assert.ok(result.stdout.includes('skipping'), 'Should say skipping'); + })) passed++; else failed++; + + // ========================================== + // validate-hooks.js + // ========================================== + console.log('\nvalidate-hooks.js:'); + + if (test('passes on real project hooks.json', () => { + const result = runValidator('validate-hooks'); + assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Validated'), 'Should output validation count'); + })) passed++; else failed++; + + if (test('exits 0 when hooks.json does not exist', () => { + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', '/nonexistent/hooks.json'); + assert.strictEqual(result.code, 0, 'Should skip when no hooks.json'); + })) passed++; else failed++; + + if (test('fails on invalid JSON', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, '{ not valid json }}}'); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on invalid JSON'); + assert.ok(result.stderr.includes('Invalid JSON'), 'Should report invalid JSON'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on invalid event type', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + InvalidEventType: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo hi' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on invalid event type'); + assert.ok(result.stderr.includes('Invalid event type'), 'Should report invalid event type'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on hook entry missing type field', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ command: 'echo hi' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on missing type'); + assert.ok(result.stderr.includes('type'), 'Should report missing type'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on hook entry missing command field', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on missing command'); + assert.ok(result.stderr.includes('command'), 'Should report missing command'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on invalid async field type', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', async: 'yes' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on non-boolean async'); + assert.ok(result.stderr.includes('async'), 'Should report async type error'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on negative timeout', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: -5 }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on negative timeout'); + assert.ok(result.stderr.includes('timeout'), 'Should report timeout error'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on invalid inline JS syntax', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "function {"' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on invalid inline JS'); + assert.ok(result.stderr.includes('invalid inline JS'), 'Should report JS syntax error'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('passes valid inline JS commands', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(1+2)"' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 0, 'Should pass valid inline JS'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('validates array command format', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', '-e', 'console.log(1)'] }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 0, 'Should accept array command format'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('validates legacy array format', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify([ + { matcher: 'test', hooks: [{ type: 'command', command: 'echo ok' }] } + ])); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 0, 'Should accept legacy array format'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on matcher missing hooks array', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'test' }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should fail on missing hooks array'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + // ========================================== + // validate-skills.js + // ========================================== + console.log('\nvalidate-skills.js:'); + + if (test('passes on real project skills', () => { + const result = runValidator('validate-skills'); + assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Validated'), 'Should output validation count'); + })) passed++; else failed++; + + if (test('exits 0 when directory does not exist', () => { + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', '/nonexistent/dir'); + assert.strictEqual(result.code, 0, 'Should skip when no skills dir'); + })) passed++; else failed++; + + if (test('fails on skill directory without SKILL.md', () => { + const testDir = createTestDir(); + fs.mkdirSync(path.join(testDir, 'broken-skill')); + // No SKILL.md inside + + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should fail on missing SKILL.md'); + assert.ok(result.stderr.includes('Missing SKILL.md'), 'Should report missing SKILL.md'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('fails on empty SKILL.md', () => { + const testDir = createTestDir(); + const skillDir = path.join(testDir, 'empty-skill'); + fs.mkdirSync(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), ''); + + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should fail on empty SKILL.md'); + assert.ok(result.stderr.includes('Empty'), 'Should report empty file'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('passes on valid skill directory', () => { + const testDir = createTestDir(); + const skillDir = path.join(testDir, 'good-skill'); + fs.mkdirSync(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# My Skill\nDescription here.'); + + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should pass for valid skill'); + assert.ok(result.stdout.includes('Validated 1'), 'Should report 1 validated'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('ignores non-directory entries', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'not-a-skill.md'), '# README'); + const skillDir = path.join(testDir, 'real-skill'); + fs.mkdirSync(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Skill'); + + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should ignore non-directory entries'); + assert.ok(result.stdout.includes('Validated 1'), 'Should count only directories'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + // ========================================== + // validate-commands.js + // ========================================== + console.log('\nvalidate-commands.js:'); + + if (test('passes on real project commands', () => { + const result = runValidator('validate-commands'); + assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Validated'), 'Should output validation count'); + })) passed++; else failed++; + + if (test('exits 0 when directory does not exist', () => { + const result = runValidatorWithDir('validate-commands', 'COMMANDS_DIR', '/nonexistent/dir'); + assert.strictEqual(result.code, 0, 'Should skip when no commands dir'); + })) passed++; else failed++; + + if (test('fails on empty command file', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'empty.md'), ''); + + const result = runValidatorWithDir('validate-commands', 'COMMANDS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should fail on empty file'); + assert.ok(result.stderr.includes('Empty'), 'Should report empty file'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('passes on valid command files', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'deploy.md'), '# Deploy\nDeploy the application.'); + fs.writeFileSync(path.join(testDir, 'test.md'), '# Test\nRun all tests.'); + + const result = runValidatorWithDir('validate-commands', 'COMMANDS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should pass for valid commands'); + assert.ok(result.stdout.includes('Validated 2'), 'Should report 2 validated'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('ignores non-md files', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'script.js'), 'console.log(1)'); + fs.writeFileSync(path.join(testDir, 'valid.md'), '# Command'); + + const result = runValidatorWithDir('validate-commands', 'COMMANDS_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should ignore non-md files'); + assert.ok(result.stdout.includes('Validated 1'), 'Should count only .md files'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + // ========================================== + // validate-rules.js + // ========================================== + console.log('\nvalidate-rules.js:'); + + if (test('passes on real project rules', () => { + const result = runValidator('validate-rules'); + assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Validated'), 'Should output validation count'); + })) passed++; else failed++; + + if (test('exits 0 when directory does not exist', () => { + const result = runValidatorWithDir('validate-rules', 'RULES_DIR', '/nonexistent/dir'); + assert.strictEqual(result.code, 0, 'Should skip when no rules dir'); + })) passed++; else failed++; + + if (test('fails on empty rule file', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'empty.md'), ''); + + const result = runValidatorWithDir('validate-rules', 'RULES_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should fail on empty rule file'); + assert.ok(result.stderr.includes('Empty'), 'Should report empty file'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('passes on valid rule files', () => { + const testDir = createTestDir(); + fs.writeFileSync(path.join(testDir, 'coding.md'), '# Coding Rules\nUse immutability.'); + + const result = runValidatorWithDir('validate-rules', 'RULES_DIR', testDir); + assert.strictEqual(result.code, 0, 'Should pass for valid rules'); + assert.ok(result.stdout.includes('Validated 1'), 'Should report 1 validated'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + // Summary + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/run-all.js b/tests/run-all.js index 8978c3e5..11f08e28 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -16,7 +16,8 @@ const testFiles = [ 'lib/session-manager.test.js', 'lib/session-aliases.test.js', 'hooks/hooks.test.js', - 'integration/hooks.test.js' + 'integration/hooks.test.js', + 'ci/validators.test.js' ]; console.log('╔══════════════════════════════════════════════════════════╗');