diff --git a/commands/quality-gate.md b/commands/quality-gate.md index 01ef940b..a749bff0 100644 --- a/commands/quality-gate.md +++ b/commands/quality-gate.md @@ -39,8 +39,9 @@ Then report formatter findings and concrete remediation steps. ## Notes -Hook wiring lives in `hooks/hooks.json` (`post:quality-gate`, profiles -`standard`/`strict` via `run-with-flags.js`). +Hook wiring enters through the async PostToolUse dispatcher in +`hooks/hooks.json`. Its internal registry preserves the `post:quality-gate` +ID and the `standard`/`strict` profiles. ## Arguments diff --git a/hooks/hooks.json b/hooks/hooks.json index 8367f833..ad13859a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -135,125 +135,29 @@ ], "PostToolUse": [ { - "matcher": "Bash", + "matcher": "*", "hooks": [ { "type": "command", - "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i f.endsWith('.json'))) { try { const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); r.push({ f, s: Object.entries(d.mcpServers || {}).map(([k, v]) => ({ n: k, cmd: typeof v === 'object' ? (v.command || v.url || '') : String(v), args: v.args || [], env: v.env ? Object.keys(v.env).reduce((a,k)=>{a[k]='••••••'; return a;}, {}) : {}, type: v.type || 'stdio' })) }); } catch (e) { console.error('[ECC] Failed to parse mcp-configs/' + f + ':', e.message); } } } return r; } +function loadPostToolUseChildren(root) { + if (path.resolve(root) !== ROOT) return []; + try { + const dispatcher = require(path.join(root, 'scripts', 'hooks', 'posttooluse-dispatcher.js')); + return [ + ...dispatcher.SYNC_HOOKS.map(hook => ({ ...hook, mode: 'sync' })), + ...dispatcher.ASYNC_HOOKS.map(hook => ({ ...hook, mode: 'async' })), + ].map(hook => ({ + ev: 'PostToolUse', + m: hook.matcher, + id: hook.id, + d: `Managed by the consolidated PostToolUse ${hook.mode} dispatcher`, + })); + } catch (error) { + console.error('[ECC] Failed to load PostToolUse dispatcher registry:', error.message); + return []; + } +} function loadHooks(_root) { const root = _root || ROOT; - const p = path.join(root, 'hooks', 'hooks.json'); if (!fs.existsSync(p)) return []; - try { const d = JSON.parse(fs.readFileSync(p, 'utf8')); const h = []; for (const [ev, es] of Object.entries(d.hooks || {})) for (const e of es || []) h.push({ ev, m: e.matcher || '*', id: e.id || '', d: e.description || '' }); return h; } catch (e) { console.error('[ECC] Failed to parse hooks/hooks.json:', e.message); return []; } + const hooksPath = path.join(root, 'hooks', 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + try { + const data = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + const hooks = []; + for (const [eventName, entries] of Object.entries(data.hooks || {})) { + for (const entry of entries || []) { + hooks.push({ ev: eventName, m: entry.matcher || '*', id: entry.id || '', d: entry.description || '' }); + } + } + return [...hooks, ...loadPostToolUseChildren(root)]; + } catch (error) { + console.error('[ECC] Failed to parse hooks/hooks.json:', error.message); + return []; + } } const LANG = { diff --git a/scripts/hooks/post-edit-console-warn.js b/scripts/hooks/post-edit-console-warn.js index c1b69c46..8002beb9 100644 --- a/scripts/hooks/post-edit-console-warn.js +++ b/scripts/hooks/post-edit-console-warn.js @@ -12,43 +12,54 @@ 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) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { +function run(data) { + const warnings = []; try { const input = JSON.parse(data); const filePath = input.tool_input?.file_path; if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) { const content = readFile(filePath); - if (!content) { process.stdout.write(data); process.exit(0); } - const lines = content.split('\n'); - const matches = []; + if (content) { + const matches = content + .split('\n') + .map((line, index) => ({ line, index })) + .filter(item => /console\.log/.test(item.line)) + .map(item => `${item.index + 1}: ${item.line.trim()}`); - lines.forEach((line, idx) => { - if (/console\.log/.test(line)) { - matches.push((idx + 1) + ': ' + line.trim()); + if (matches.length > 0) { + warnings.push(`[Hook] WARNING: console.log found in ${filePath}`); + warnings.push(...matches.slice(0, 5)); + warnings.push('[Hook] Remove console.log before committing'); } - }); - - if (matches.length > 0) { - console.error('[Hook] WARNING: console.log found in ' + filePath); - matches.slice(0, 5).forEach(m => console.error(m)); - console.error('[Hook] Remove console.log before committing'); } } } catch { // Invalid input — pass through } - process.stdout.write(data); - process.exit(0); -}); + return { + stdout: data, + stderr: warnings.join('\n'), + exitCode: 0, + }; +} + +if (require.main === module) { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (data.length < MAX_STDIN) { + const remaining = MAX_STDIN - data.length; + data += chunk.substring(0, remaining); + } + }); + process.stdin.on('end', () => { + const result = run(data); + if (result.stderr) process.stderr.write(`${result.stderr}\n`); + process.stdout.write(result.stdout); + process.exitCode = result.exitCode; + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js new file mode 100644 index 00000000..a5c3d3c4 --- /dev/null +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +/** + * Consolidates PostToolUse hooks into one synchronous and one asynchronous + * entrypoint while preserving each hook's ID, matcher, profile, and output. + */ + +'use strict'; + +const path = require('path'); +const { StringDecoder } = require('string_decoder'); +const { VALID_PROFILES, normalizeId, parseProfiles } = require('../lib/hook-flags'); +const { runPostBash } = require('./bash-hook-dispatcher'); +const { run: runQualityGate } = require('./quality-gate'); +const { run: runDesignQualityCheck } = require('./design-quality-check'); +const { run: runPostEditAccumulator } = require('./post-edit-accumulator'); +const { run: runConsoleWarn } = require('./post-edit-console-warn'); +const { run: runGovernanceCapture } = require('./governance-capture'); +const { run: runSessionActivityTracker } = require('./session-activity-tracker'); +const { run: runObserve } = require('./observe-runner'); +const { run: runMetricsBridge } = require('./ecc-metrics-bridge'); +const { run: runContextMonitor } = require('./ecc-context-monitor'); + +const MAX_STDIN = 1024 * 1024; + +const SYNC_HOOKS = [ + { id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck }, + { id: 'post:edit:accumulator', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-accumulator.js', run: runPostEditAccumulator }, + { id: 'post:edit:console-warn', matcher: 'Edit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-console-warn.js', run: runConsoleWarn }, + { id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture }, + { id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker }, + { id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge }, + { id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor } +]; + +const ASYNC_HOOKS = [ + { + id: 'post:bash:dispatcher', + matcher: 'Bash', + // main ran this phase unconditionally; sub-hooks gate themselves internally + profiles: 'minimal,standard,strict', + script: 'scripts/hooks/post-bash-dispatcher.js', + run(raw) { + const result = runPostBash(raw); + return { stdout: result.output, stderr: result.stderr, exitCode: result.exitCode }; + } + }, + { id: 'post:quality-gate', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/quality-gate.js', run: runQualityGate }, + { id: 'post:observe:continuous-learning', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/observe-runner.js', run: runObserve } +]; + +function getPluginRoot(env = process.env) { + return env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..'); +} + +function matchesTool(matcher, toolName) { + return ( + matcher === '*' || + String(matcher || '') + .split('|') + .map(value => value.trim()) + .filter(Boolean) + .includes(String(toolName || '')) + ); +} + +function isEnabled(hook, env) { + const disabled = new Set( + String(env.ECC_DISABLED_HOOKS || '') + .split(',') + .map(normalizeId) + .filter(Boolean) + ); + const requestedProfile = String(env.ECC_HOOK_PROFILE || 'standard') + .trim() + .toLowerCase(); + const profile = VALID_PROFILES.has(requestedProfile) ? requestedProfile : 'standard'; + return !disabled.has(normalizeId(hook.id)) && parseProfiles(hook.profiles).includes(profile); +} + +function extractToolName(raw) { + try { + return String(JSON.parse(raw)?.tool_name || ''); + } catch { + return ''; + } +} + +function buildDryRunPreview(hook, raw) { + let target = ''; + try { + const input = JSON.parse(raw)?.tool_input || {}; + target = String(input.file_path || input.path || input.command || ''); + } catch { + target = ''; + } + const suffix = target ? ` target=${target}` : ''; + return `[DryRun] Hook "${hook.id}" would execute: ${hook.script} (enabled=true, profiles=${hook.profiles})${suffix}\n`; +} + +function normalizeResult(raw, output) { + if (typeof output === 'string' || Buffer.isBuffer(output)) { + const stdout = String(output); + return { stdout: stdout !== raw ? stdout : '', stderr: '', exitCode: 0 }; + } + if (!output || typeof output !== 'object') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + + let stdout = ''; + if (Object.prototype.hasOwnProperty.call(output, 'stdout')) { + stdout = String(output.stdout ?? ''); + } else if (Object.prototype.hasOwnProperty.call(output, 'output')) { + stdout = String(output.output ?? ''); + } else if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) { + stdout = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: String(output.additionalContext ?? '') + } + }); + } + + return { + stdout: stdout !== raw ? stdout : '', + stderr: typeof output.stderr === 'string' ? output.stderr : '', + exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0 + }; +} + +function appendLine(current, next) { + if (!next) return current; + return current + (String(next).endsWith('\n') ? String(next) : `${next}\n`); +} + +function parseAdditionalContext(stdout) { + try { + const parsed = JSON.parse(stdout); + const output = parsed?.hookSpecificOutput; + if (output?.hookEventName !== 'PostToolUse') return null; + return typeof output.additionalContext === 'string' ? output.additionalContext : null; + } catch { + return null; + } +} + +function mergeHookStdout(outputs) { + if (outputs.length === 0) return { stdout: '', warning: '' }; + if (outputs.length === 1) return { stdout: outputs[0].stdout, warning: '' }; + + const contexts = outputs.map(output => parseAdditionalContext(output.stdout)); + if (contexts.every(context => context !== null)) { + return { + stdout: JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: contexts.join('\n') + } + }), + warning: '' + }; + } + + const kept = outputs[outputs.length - 1]; + const dropped = outputs + .slice(0, -1) + .map(output => output.id) + .join(', '); + return { + stdout: kept.stdout, + warning: `[Hook] stdout from ${dropped} dropped in favor of ${kept.id}; raw stdout cannot be merged` + }; +} + +function runHooks(raw, hooks, options = {}) { + const env = options.env || process.env; + const toolName = options.toolName ?? extractToolName(raw); + const pluginRoot = getPluginRoot(env); + const outputs = []; + let stderr = ''; + let exitCode = 0; + + for (const hook of hooks) { + if (!matchesTool(hook.matcher, toolName) || !isEnabled(hook, env)) continue; + if (env.ECC_DRY_RUN === '1') { + stderr += buildDryRunPreview(hook, raw); + continue; + } + + try { + const result = normalizeResult( + raw, + hook.run(raw, { + hookId: hook.id, + pluginRoot, + scriptPath: path.join(pluginRoot, hook.script || ''), + truncated: options.truncated === true, + maxStdin: MAX_STDIN + }) + ); + if (result.stdout) outputs.push({ id: hook.id, stdout: result.stdout }); + stderr = appendLine(stderr, result.stderr); + if (result.exitCode !== 0) { + if (exitCode === 0) exitCode = result.exitCode; + stderr = appendLine(stderr, `[Hook] ${hook.id} exited with code ${result.exitCode}; continuing`); + } + } catch (error) { + stderr = appendLine(stderr, `[Hook] ${hook.id} failed: ${error.message}`); + } + } + + const merged = mergeHookStdout(outputs); + if (merged.warning) stderr = appendLine(stderr, merged.warning); + return { stdout: merged.stdout, stderr, exitCode }; +} + +function readStdinRaw() { + return new Promise(resolve => { + const decoder = new StringDecoder('utf8'); + let raw = ''; + let bytesRead = 0; + let truncated = false; + let settled = false; + process.stdin.on('data', chunk => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, MAX_STDIN - bytesRead); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + raw += decoder.write(accepted); + bytesRead += accepted.length; + } + if (buffer.length > accepted.length) truncated = true; + }); + const finish = () => { + if (settled) return; + settled = true; + if (!truncated) raw += decoder.end(); + resolve({ raw, truncated }); + }; + process.stdin.once('end', finish); + process.stdin.once('error', finish); + }); +} + +function resolveMainStdout(raw, result, options = {}) { + if (result.stdout) return result.stdout; + if (options.truncated || result.exitCode !== 0 || !options.passthrough) return ''; + return raw; +} + +async function main() { + const mode = process.argv[2] === 'async' ? 'async' : 'sync'; + const { raw, truncated } = await readStdinRaw(); + const dispatcherId = `post:dispatcher:${mode}`; + const dispatcherEnabled = isEnabled( + { + id: dispatcherId, + profiles: 'minimal,standard,strict' + }, + process.env + ); + const hooks = dispatcherEnabled ? (mode === 'async' ? ASYNC_HOOKS : SYNC_HOOKS) : []; + const result = runHooks(raw, hooks, { truncated }); + if (truncated) { + process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for PostToolUse ${mode}; suppressing pass-through\n`); + } + if (result.stderr) process.stderr.write(result.stderr); + const stdout = resolveMainStdout(raw, result, { + passthrough: process.env.ECC_POSTTOOLUSE_PASSTHROUGH === '1', + truncated + }); + if (stdout) process.stdout.write(stdout); + process.exitCode = result.exitCode; +} + +function cli() { + main().catch(error => { + process.stderr.write(`[Hook] PostToolUse dispatcher failed: ${error.message}\n`); + process.exitCode = 0; + }); +} + +if (require.main === module) cli(); + +module.exports = { + ASYNC_HOOKS, + SYNC_HOOKS, + cli, + matchesTool, + main, + mergeHookStdout, + normalizeResult, + resolveMainStdout, + runHooks +}; diff --git a/tests/hooks/continuous-learning-observe-runner.test.js b/tests/hooks/continuous-learning-observe-runner.test.js index 5abb1907..37ca3ce3 100644 --- a/tests/hooks/continuous-learning-observe-runner.test.js +++ b/tests/hooks/continuous-learning-observe-runner.test.js @@ -16,6 +16,7 @@ const repoRoot = path.resolve(__dirname, '..', '..'); const hooksJsonPath = path.join(repoRoot, 'hooks', 'hooks.json'); const runWithFlagsPath = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js'); const observeRunner = require(path.join(repoRoot, 'scripts', 'hooks', 'observe-runner.js')); +const postToolUseDispatcher = require(path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js')); function test(name, fn) { try { @@ -115,14 +116,14 @@ function runTests() { let failed = 0; if (test('observe hooks use node-mode runner instead of shell-mode dispatch', () => { - for (const hookId of ['pre:observe:continuous-learning', 'post:observe:continuous-learning']) { - const command = loadHook(hookId); - const phase = hookId.startsWith('pre:') ? 'pre:observe' : 'post:observe'; + const preCommand = loadHook('pre:observe:continuous-learning'); + assert.ok(preCommand.includes('node scripts/hooks/run-with-flags.js pre:observe scripts/hooks/observe-runner.js standard,strict')); + assert.ok(!preCommand.includes('shell scripts/hooks/run-with-flags-shell.sh')); + assert.ok(!preCommand.includes('skills/continuous-learning-v2/hooks/observe.sh')); - assert.ok(command.includes(`node scripts/hooks/run-with-flags.js ${phase} scripts/hooks/observe-runner.js standard,strict`)); - assert.ok(!command.includes('shell scripts/hooks/run-with-flags-shell.sh'), `${hookId} should not use shell-mode bootstrap`); - assert.ok(!command.includes('skills/continuous-learning-v2/hooks/observe.sh'), `${hookId} should not call observe.sh directly from hooks.json`); - } + const postHook = postToolUseDispatcher.ASYNC_HOOKS.find(hook => hook.id === 'post:observe:continuous-learning'); + assert.ok(postHook, 'PostToolUse dispatcher should retain the observe hook ID'); + assert.strictEqual(postHook.script, 'scripts/hooks/observe-runner.js'); })) passed++; else failed++; if (test('run-with-flags passes hookId to direct run exports', () => { diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 52effc86..2830fbed 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -2476,23 +2476,29 @@ async function runTests() { else failed++; if ( - test('hooks.json consolidates Bash hooks into one pre and one post dispatcher', () => { + test('hooks.json consolidates PreToolUse Bash and all PostToolUse hooks', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash'); - const postBash = hooks.hooks.PostToolUse.filter(entry => entry.matcher === 'Bash'); + const postEntries = hooks.hooks.PostToolUse; assert.strictEqual(preBash.length, 1, 'Should have exactly one PreToolUse Bash dispatcher'); - assert.strictEqual(postBash.length, 1, 'Should have exactly one PostToolUse Bash dispatcher'); assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher'); - assert.strictEqual(postBash[0].id, 'post:bash:dispatcher'); + assert.deepStrictEqual( + postEntries.map(entry => entry.id), + ['post:dispatcher:sync', 'post:dispatcher:async'], + 'PostToolUse should have one sync and one async dispatcher' + ); + assert.ok(postEntries.every(entry => entry.matcher === '*')); const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command; - const postCommand = Array.isArray(postBash[0].hooks[0].command) ? postBash[0].hooks[0].command.join(' ') : postBash[0].hooks[0].command; assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher'); - assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher'); + assert.ok(postEntries[0].hooks[0].command.includes('posttooluse-dispatcher.js')); + assert.ok(postEntries[0].hooks[0].command.endsWith('" sync')); + assert.ok(postEntries[1].hooks[0].command.includes('posttooluse-dispatcher.js')); + assert.ok(postEntries[1].hooks[0].command.endsWith('" async')); }) ) passed++; @@ -2643,8 +2649,9 @@ async function runTests() { if (hook.type === 'command' && commandText.includes('scripts/hooks/')) { const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js'); const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js'); + const usesDirectPostDispatcher = commandStart.startsWith('node -e') && commandText.includes('posttooluse-dispatcher.js') && commandText.includes('resolve-ecc-root'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`); - assert.ok(usesInlineResolver || usesPluginBootstrap, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); + assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); } } } @@ -4227,9 +4234,15 @@ async function runTests() { console.log('\nRound 29: post-edit-console-warn.js (extension and exit):'); if ( - await asyncTest('source calls process.exit(0) after writing output', async () => { - const cwSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-console-warn.js'), 'utf8'); - assert.ok(cwSource.includes('process.exit(0)'), 'Should call process.exit(0)'); + await asyncTest('exports a require-safe run function', async () => { + const consoleWarn = require(path.join(scriptsDir, 'post-edit-console-warn.js')); + const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); + assert.strictEqual(typeof consoleWarn.run, 'function'); + assert.deepStrictEqual(consoleWarn.run(stdinJson), { + stdout: stdinJson, + stderr: '', + exitCode: 0, + }); }) ) passed++; diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js new file mode 100644 index 00000000..afa5db29 --- /dev/null +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -0,0 +1,465 @@ +/** + * Contract tests for the consolidated PostToolUse dispatchers. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..', '..'); +const hooksPath = path.join(repoRoot, 'hooks', 'hooks.json'); +const dispatcherPath = path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runDispatcher(mode, toolName, env = {}) { + const raw = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: toolName, + tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') }, + tool_response: {} + }); + + return spawnSync(process.execPath, [dispatcherPath, mode], { + cwd: repoRoot, + input: raw, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: repoRoot, + ECC_PLUGIN_ROOT: repoRoot, + ...env + }, + timeout: 10000 + }); +} + +function previewedIds(stderr) { + return [...String(stderr).matchAll(/Hook "([^"]+)"/g)].map(match => match[1]); +} + +function runConfiguredCommand(entry, raw, env = {}) { + return spawnSync(entry.hooks[0].command, { + shell: true, + cwd: repoRoot, + input: raw, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: repoRoot, + ECC_PLUGIN_ROOT: repoRoot, + ...env + }, + timeout: 10000 + }); +} + +function runTests() { + console.log('\n=== PostToolUse dispatcher tests ===\n'); + + let passed = 0; + let failed = 0; + + if ( + test('hooks.json exposes one sync and one async PostToolUse entry', () => { + const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse; + assert.strictEqual(entries.length, 2, 'PostToolUse should launch at most two commands'); + assert.deepStrictEqual( + entries.map(entry => entry.id), + ['post:dispatcher:sync', 'post:dispatcher:async'] + ); + assert.ok(entries.every(entry => entry.matcher === '*')); + assert.strictEqual(entries[0].hooks[0].async, undefined); + assert.strictEqual(entries[1].hooks[0].async, true); + assert.ok(entries[0].hooks[0].command.includes('posttooluse-dispatcher.js')); + assert.ok(entries[0].hooks[0].command.endsWith('" sync')); + assert.ok(entries[1].hooks[0].command.includes('posttooluse-dispatcher.js')); + assert.ok(entries[1].hooks[0].command.endsWith('" async')); + assert.ok(entries.every(entry => entry.hooks[0].command.includes('resolve-ecc-root'))); + assert.ok( + entries.every(entry => !entry.hooks[0].command.includes('plugin-hook-bootstrap.js')), + 'PostToolUse dispatchers should not spawn a second Node bootstrap process' + ); + assert.ok(entries[1].hooks[0].timeout >= 30); + }) + ) + passed++; + else failed++; + + if ( + test('dry-run selects the original IDs by tool and phase', () => { + const cases = [ + { + tool: 'Edit', + sync: [ + 'post:edit:design-quality-check', + 'post:edit:accumulator', + 'post:edit:console-warn', + 'post:governance-capture', + 'post:session-activity-tracker', + 'post:ecc-metrics-bridge', + 'post:ecc-context-monitor' + ], + async: ['post:quality-gate', 'post:observe:continuous-learning'] + }, + { + tool: 'Write', + sync: ['post:edit:design-quality-check', 'post:edit:accumulator', 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + async: ['post:quality-gate', 'post:observe:continuous-learning'] + }, + { + tool: 'Bash', + sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + async: ['post:bash:dispatcher', 'post:observe:continuous-learning'] + }, + { + tool: 'Read', + sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + async: ['post:observe:continuous-learning'] + } + ]; + + for (const expected of cases) { + const sync = runDispatcher('sync', expected.tool, { ECC_DRY_RUN: '1' }); + const asyncResult = runDispatcher('async', expected.tool, { ECC_DRY_RUN: '1' }); + assert.strictEqual(sync.status, 0, sync.stderr); + assert.strictEqual(asyncResult.status, 0, asyncResult.stderr); + assert.deepStrictEqual(previewedIds(sync.stderr), expected.sync, `${expected.tool} sync IDs`); + assert.deepStrictEqual(previewedIds(asyncResult.stderr), expected.async, `${expected.tool} async IDs`); + assert.strictEqual(sync.stdout, ''); + assert.strictEqual(asyncResult.stdout, ''); + } + }) + ) + passed++; + else failed++; + + if ( + test('actual hooks.json commands preserve Edit dry-run output and IDs', () => { + const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse; + const raw = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') }, + tool_response: {} + }); + const results = entries.map(entry => runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' })); + + for (const result of results) { + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, raw, 'configured command should preserve pass-through output'); + } + const ids = results.flatMap(result => previewedIds(result.stderr)); + assert.deepStrictEqual(ids, [ + 'post:edit:design-quality-check', + 'post:edit:accumulator', + 'post:edit:console-warn', + 'post:governance-capture', + 'post:session-activity-tracker', + 'post:ecc-metrics-bridge', + 'post:ecc-context-monitor', + 'post:quality-gate', + 'post:observe:continuous-learning' + ]); + }) + ) + passed++; + else failed++; + + if ( + test('actual hooks.json commands never echo truncated oversized input', () => { + const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse; + const values = ['x'.repeat(1024 * 1024 + 1024), 'é'.repeat(600000), '\u{1F600}'.repeat(300000)]; + + for (const value of values) { + const raw = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { value }, + tool_response: {} + }); + assert.ok(Buffer.byteLength(raw, 'utf8') > 1024 * 1024); + + for (const entry of entries) { + const result = runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, '', `${entry.id} should suppress truncated pass-through`); + assert.ok(result.stderr.includes('stdin exceeded'), `${entry.id} should report truncation`); + } + } + }) + ) + passed++; + else failed++; + + if ( + test('profiles and disabled IDs remain scoped to each original hook', () => { + const minimalSync = runDispatcher('sync', 'Edit', { + ECC_DRY_RUN: '1', + ECC_HOOK_PROFILE: 'minimal' + }); + assert.strictEqual(minimalSync.status, 0, minimalSync.stderr); + assert.deepStrictEqual(previewedIds(minimalSync.stderr), ['post:ecc-metrics-bridge']); + + const minimalAsync = runDispatcher('async', 'Bash', { + ECC_DRY_RUN: '1', + ECC_HOOK_PROFILE: 'minimal' + }); + assert.strictEqual(minimalAsync.status, 0, minimalAsync.stderr); + assert.deepStrictEqual(previewedIds(minimalAsync.stderr), ['post:bash:dispatcher'], 'bash dispatcher phase must stay reachable in minimal profile like main; its sub-hooks gate themselves'); + + const disabled = runDispatcher('sync', 'Edit', { + ECC_DRY_RUN: '1', + ECC_DISABLED_HOOKS: 'post:edit:accumulator' + }); + assert.strictEqual(disabled.status, 0, disabled.stderr); + const ids = previewedIds(disabled.stderr); + assert.ok(!ids.includes('post:edit:accumulator')); + assert.ok(ids.includes('post:edit:design-quality-check')); + assert.ok(ids.includes('post:ecc-context-monitor')); + }) + ) + passed++; + else failed++; + + if ( + test('public dispatcher IDs disable their complete phase', () => { + const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse; + const raw = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') }, + tool_response: {} + }); + + for (const entry of entries) { + const result = runConfiguredCommand(entry, raw, { + ECC_DRY_RUN: '1', + ECC_DISABLED_HOOKS: entry.id + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(previewedIds(result.stderr), [], `${entry.id} should disable all child hooks`); + assert.strictEqual(result.stdout, raw); + } + }) + ) + passed++; + else failed++; + + if ( + test('dry-run has no PostToolUse side effects', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-posttooluse-dry-run-')); + try { + const result = runDispatcher('sync', 'Edit', { + ECC_DRY_RUN: '1', + HOME: homeDir, + USERPROFILE: homeDir, + CLAUDE_SESSION_ID: 'dry-run-session' + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(fs.readdirSync(homeDir), []); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('dispatcher isolates failures and preserves explicit output and exit status', () => { + assert.ok(fs.existsSync(dispatcherPath), 'dispatcher module should exist'); + const { resolveMainStdout, runHooks } = require(dispatcherPath); + const calls = []; + const raw = JSON.stringify({ tool_name: 'Read' }); + const explicitOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'context warning' + } + }); + const hooks = [ + { + id: 'post:test:first', + matcher: '*', + profiles: 'standard,strict', + run: input => { + calls.push(['first', input]); + return input; + } + }, + { + id: 'post:test:broken', + matcher: '*', + profiles: 'standard,strict', + run: () => { + throw new Error('boom'); + } + }, + { id: 'post:test:nonzero', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) }, + { + id: 'post:test:last', + matcher: '*', + profiles: 'standard,strict', + run: input => { + calls.push(['last', input]); + return { stdout: explicitOutput, stderr: 'last warning' }; + } + } + ]; + + const result = runHooks(raw, hooks, { toolName: 'Read', env: { ECC_HOOK_PROFILE: 'standard' } }); + assert.deepStrictEqual( + calls, + [ + ['first', raw], + ['last', raw] + ], + 'each hook should receive the original input' + ); + assert.strictEqual(result.stdout, explicitOutput); + assert.ok(result.stderr.includes('post:test:broken')); + assert.ok(result.stderr.includes('boom')); + assert.ok(result.stderr.includes('post:test:nonzero')); + assert.ok(result.stderr.indexOf('post:test:broken') < result.stderr.indexOf('last warning')); + assert.strictEqual(result.exitCode, 7, 'explicit child exit codes should be preserved'); + assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 7 }, { passthrough: true, truncated: false }), '', 'nonzero results should not restore raw input'); + }) + ) + passed++; + else failed++; + + if ( + test('failing hook exit code propagates to the real dispatcher process status', () => { + const script = [ + `const dispatcher = require(${JSON.stringify(dispatcherPath)});`, + 'dispatcher.SYNC_HOOKS.length = 0;', + "dispatcher.SYNC_HOOKS.push({ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) });", + "process.argv[2] = 'sync';", + 'dispatcher.cli();' + ].join(''); + const result = spawnSync(process.execPath, ['-e', script], { + cwd: repoRoot, + input: JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: {}, tool_response: {} }), + encoding: 'utf8', + env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' }, + timeout: 10000 + }); + assert.strictEqual(result.status, 7, 'OS-level exit status should reflect the failing hook'); + assert.ok(result.stderr.includes('post:test:fail exited with code 7'), result.stderr); + assert.strictEqual(result.stdout, '', 'failed runs must not restore pass-through output'); + }) + ) + passed++; + else failed++; + + if ( + test('console warning hook is safe to require in-process', () => { + const script = [`const hook = require(${JSON.stringify(path.join(repoRoot, 'scripts', 'hooks', 'post-edit-console-warn.js'))});`, "if (typeof hook.run !== 'function') process.exit(2);"].join( + '' + ); + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 5000 }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + }) + ) + passed++; + else failed++; + + if ( + test('empty and malformed input fail open', () => { + for (const input of ['', '{not-json']) { + const result = spawnSync(process.execPath, [dispatcherPath, 'sync'], { + cwd: repoRoot, + input, + encoding: 'utf8', + env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot }, + timeout: 10000 + }); + assert.strictEqual(result.status, 0, result.stderr); + } + }) + ) + passed++; + else failed++; + + if ( + test('multiple additionalContext outputs merge; raw stdout conflicts warn', () => { + const { mergeHookStdout, runHooks } = require(dispatcherPath); + const envelope = context => + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } + }); + const contextHook = (id, context) => ({ + id, + matcher: '*', + profiles: 'standard,strict', + run: () => ({ additionalContext: context }) + }); + + const merged = runHooks(JSON.stringify({ tool_name: 'Read' }), [contextHook('post:test:one', 'first warning'), contextHook('post:test:two', 'second warning')], { + toolName: 'Read', + env: { ECC_HOOK_PROFILE: 'standard' } + }); + assert.strictEqual(merged.stdout, envelope('first warning\nsecond warning'), 'context envelopes should merge into one'); + assert.ok(!merged.stderr.includes('dropped'), merged.stderr); + + const conflicting = mergeHookStdout([ + { id: 'post:test:raw', stdout: 'plain output' }, + { id: 'post:test:ctx', stdout: envelope('kept warning') } + ]); + assert.strictEqual(conflicting.stdout, envelope('kept warning'), 'last output should win when raw stdout cannot merge'); + assert.ok(conflicting.warning.includes('post:test:raw'), 'dropped hook IDs should be named'); + assert.ok(conflicting.warning.includes('post:test:ctx')); + }) + ) + passed++; + else failed++; + + if ( + test('requiring the dispatcher module never dispatches; hooks.json calls cli()', () => { + const raw = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: {}, + tool_response: {} + }); + const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(dispatcherPath)})`], { + cwd: repoRoot, + input: raw, + encoding: 'utf8', + env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' }, + timeout: 10000 + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, '', 'require() alone must not run main() or echo stdin'); + + const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse; + assert.ok( + entries.every(entry => entry.hooks[0].command.includes('require(s).cli()')), + 'hooks.json must invoke the explicit cli() entrypoint' + ); + }) + ) + passed++; + else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 1ab3a342..77b0822d 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -675,16 +675,23 @@ async function runTests() { })) passed++; else failed++; if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => { - const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher'); - const result = await runHookCommand(hookCommand, { - tool_input: { command: 'gh pr create --title "Test"' }, - tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' } - }); + const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:dispatcher:async'); + const testDir = createTestDir(); + try { + const result = await runHookCommand(hookCommand, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'gh pr create --title "Test"' }, + tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' } + }, { HOME: testDir, USERPROFILE: testDir }); - assert.ok( - result.stderr.includes('PR created') || result.stderr.includes('github.com'), - 'Should extract and log PR URL' - ); + assert.ok( + result.stderr.includes('PR created') || result.stderr.includes('github.com'), + 'Should extract and log PR URL' + ); + } finally { + cleanupTestDir(testDir); + } })) passed++; else failed++; // ========================================== diff --git a/tests/scripts/dashboard-web.test.js b/tests/scripts/dashboard-web.test.js index 8118a06c..ed837caa 100644 --- a/tests/scripts/dashboard-web.test.js +++ b/tests/scripts/dashboard-web.test.js @@ -540,6 +540,18 @@ test('loadHooks loads hook definitions', () => { cleanup(testRoot); }); +test('loadHooks exposes consolidated PostToolUse child IDs', () => { + const { loadHooks } = require(SCRIPT); + const repoRoot = path.join(__dirname, '..', '..'); + const hooks = loadHooks(repoRoot); + + assert.ok(hooks.some(hook => hook.id === 'post:dispatcher:sync')); + assert.ok(hooks.some(hook => hook.id === 'post:dispatcher:async')); + assert.ok(hooks.some(hook => hook.id === 'post:quality-gate')); + assert.ok(hooks.some(hook => hook.id === 'post:edit:accumulator')); + assert.ok(hooks.some(hook => hook.id === 'post:ecc-context-monitor')); +}); + test('loadHooks handles malformed JSON gracefully', () => { const { loadHooks } = require(SCRIPT); testRoot = createTempDir('ecc-test-');