diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 9c3f9d23b..cba3d1394 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -5,7 +5,7 @@ { "command": "node .cursor/hooks/session-start.js", "event": "sessionStart", - "description": "Load previous context and detect environment" + "description": "Set the Cursor data root, load previous context, and detect the environment" } ], "sessionEnd": [ @@ -16,15 +16,10 @@ } ], "beforeShellExecution": [ - { - "command": "node .cursor/hooks/before-shell-execution-block-no-verify.js", - "event": "beforeShellExecution", - "description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped" - }, { "command": "node .cursor/hooks/before-shell-execution.js", "event": "beforeShellExecution", - "description": "Tmux dev server blocker, tmux reminder, git push review" + "description": "Dispatch git hook-bypass protection, dev-server blocking, tmux reminders, and git push review" } ], "afterShellExecution": [ @@ -38,7 +33,7 @@ { "command": "node .cursor/hooks/after-file-edit.js", "event": "afterFileEdit", - "description": "Auto-format, TypeScript check, console.log warning, and frontend design-quality reminder" + "description": "Accumulate JS/TS edits, warn on console.log, and run the frontend design-quality reminder" } ], "beforeMCPExecution": [ @@ -108,7 +103,8 @@ { "command": "node .cursor/hooks/stop.js", "event": "stop", - "description": "Console.log audit on all modified files" + "description": "Batch format and typecheck edits, audit console.log usage, and persist session state", + "timeout": 300 } ] } diff --git a/.cursor/hooks/adapter.js b/.cursor/hooks/adapter.js index 8ac8d298c..e019df446 100644 --- a/.cursor/hooks/adapter.js +++ b/.cursor/hooks/adapter.js @@ -5,31 +5,113 @@ * then delegates to existing scripts/hooks/*.js */ -const { execFileSync } = require('child_process'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); const path = require('path'); -const MAX_STDIN = 1024 * 1024; +const MAX_STDIN_BYTES = 1024 * 1024; +const STOP_FORMAT_TYPECHECK_BUDGET_MS = 210000; +const STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS = 15000; -function readStdin() { +function readStdin(options = {}) { return new Promise((resolve) => { + const stream = options.stream || process.stdin; let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length); + let dataBytes = 0; + let truncated = false; + stream.setEncoding('utf8'); + stream.on('data', chunk => { + const chunkBytes = Buffer.byteLength(chunk, 'utf8'); + if (truncated || dataBytes + chunkBytes > MAX_STDIN_BYTES) { + truncated = true; + return; + } + data += chunk; + dataBytes += chunkBytes; + }); + stream.on('end', () => { + if (truncated) { + if (options.includeMetadata !== true) { + process.stderr.write( + `[Cursor Hook] stdin exceeded ${MAX_STDIN_BYTES} bytes; suppressing truncated input\n` + ); + } + resolve(options.includeMetadata === true + ? { raw: '', truncated: true, readError: false } + : ''); + return; + } + resolve(options.includeMetadata === true + ? { raw: data, truncated: false, readError: false } + : data); + }); + stream.on('error', error => { + process.stderr.write(`[Cursor Hook] stdin read failed: ${error.message}\n`); + resolve(options.includeMetadata === true + ? { raw: '', truncated: false, readError: true } + : ''); }); - process.stdin.on('end', () => resolve(data)); }); } function getPluginRoot() { - return path.resolve(__dirname, '..', '..'); + const candidates = [ + // Installed layout: /.cursor/hooks -> /.cursor/scripts. + path.resolve(__dirname, '..'), + // Repository layout: /.cursor/hooks -> /scripts. + path.resolve(__dirname, '..', '..'), + ]; + + return candidates.find(candidate => ( + fs.existsSync(path.join(candidate, 'scripts', 'hooks')) + && fs.existsSync(path.join(candidate, 'scripts', 'lib')) + )) || candidates[0]; +} + +function resolveRuntimePath(...segments) { + const runtimeRoot = getPluginRoot(); + const resolvedRoot = path.resolve(runtimeRoot); + const resolvedPath = path.resolve(runtimeRoot, ...segments); + if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`Cursor hook runtime path escapes its root: ${resolvedPath}`); + } + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Cursor hook runtime file not found: ${resolvedPath}`); + } + return resolvedPath; +} + +function loadRuntimeModule(...segments) { + return require(resolveRuntimePath(...segments)); +} + +function fallbackHookEnabled(hookId, allowedProfiles) { + const enabled = String(process.env.ECC_HOOKS_ENABLED ?? 'true').trim().toLowerCase(); + if (['0', 'false', 'no', 'off'].includes(enabled)) { + return false; + } + + const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase(); + const profile = ['minimal', 'standard', 'strict'].includes(rawProfile) + ? rawProfile + : 'standard'; + const disabled = new Set( + String(process.env.ECC_DISABLED_HOOKS || '') + .split(',') + .map(value => value.trim().toLowerCase()) + .filter(Boolean) + ); + + return !disabled.has(String(hookId || '').trim().toLowerCase()) + && allowedProfiles.includes(profile); } function transformToClaude(cursorInput, overrides = {}) { + const cursorSessionId = cursorInput.conversation_id || cursorInput.session_id || ''; return { tool_input: { command: cursorInput.command || cursorInput.args?.command || '', - file_path: cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '', + file_path: cursorInput.file_path || cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '', ...overrides.tool_input, }, tool_output: { @@ -37,6 +119,7 @@ function transformToClaude(cursorInput, overrides = {}) { ...overrides.tool_output, }, transcript_path: cursorInput.transcript_path || cursorInput.transcriptPath || cursorInput.session?.transcript_path || '', + session_id: cursorSessionId, _cursor: { conversation_id: cursorInput.conversation_id, hook_event_name: cursorInput.hook_event_name, @@ -46,36 +129,123 @@ function transformToClaude(cursorInput, overrides = {}) { }; } -function runExistingHook(scriptName, stdinData) { - const scriptPath = path.join(getPluginRoot(), 'scripts', 'hooks', scriptName); +function normalizeSessionStartOutput(rawOutput, options = {}) { + if (!rawOutput) return {}; + const parsed = JSON.parse(rawOutput); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return { + ...(parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? { env: parsed.env } + : {}), + ...(typeof parsed.additional_context === 'string' && parsed.additional_context + ? { additional_context: parsed.additional_context } + : {}), + ...(options.shared === true + && typeof parsed.hookSpecificOutput?.additionalContext === 'string' + && parsed.hookSpecificOutput.additionalContext + ? { additional_context: parsed.hookSpecificOutput.additionalContext } + : {}), + }; +} + +function mergeSessionStartOutputs(outputs) { + const validOutputs = outputs.filter(output => output && typeof output === 'object'); + const env = Object.assign({}, ...validOutputs.map(output => output.env || {})); + const additionalContext = validOutputs + .map(output => output.additional_context) + .filter(Boolean) + .join('\n\n'); + + return { + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(additionalContext ? { additional_context: additionalContext } : {}), + }; +} + +function createStopFormatTypecheckOptions(sessionEnv = {}) { + return { + timeout: STOP_FORMAT_TYPECHECK_BUDGET_MS + STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS, + forwardStderr: true, + env: { + ...sessionEnv, + ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: String(STOP_FORMAT_TYPECHECK_BUDGET_MS), + }, + }; +} + +function runExistingHook(scriptName, stdinData, options = {}) { try { - execFileSync('node', [scriptPath], { + const scriptPath = resolveRuntimePath('scripts', 'hooks', scriptName); + let cursorSessionId = ''; + try { + const input = typeof stdinData === 'string' ? JSON.parse(stdinData) : stdinData; + cursorSessionId = input?._cursor?.conversation_id + || input?.conversation_id + || input?.session_id + || ''; + } catch { + // Raw non-JSON hook input has no session identifier to propagate. + } + const result = spawnSync(process.execPath, [scriptPath], { input: typeof stdinData === 'string' ? stdinData : JSON.stringify(stdinData), stdio: ['pipe', 'pipe', 'pipe'], - timeout: 15000, + encoding: 'utf8', + env: { + ...process.env, + ...(options.env || {}), + ...(cursorSessionId + ? { CLAUDE_SESSION_ID: String(cursorSessionId) } + : {}), + }, + timeout: options.timeout || 15000, cwd: process.cwd(), }); - } catch (e) { - if (e.status === 2) process.exit(2); // Forward blocking exit code + + if (result.stderr && (result.status === 2 || options.forwardStderr === true)) { + process.stderr.write(result.stderr); + } + if (result.status === 2) { + process.exit(2); + } + if (result.error || result.signal || result.status === null) { + const reason = result.error + ? result.error.message + : result.signal + ? `signal ${result.signal}` + : 'missing exit status'; + process.stderr.write(`[Cursor Hook] ${scriptName} failed: ${reason}\n`); + } else if (Number.isInteger(result.status) && result.status !== 0) { + process.stderr.write( + `[Cursor Hook] ${scriptName} exited with code ${result.status}; continuing\n` + ); + } + return result; + } catch (error) { + process.stderr.write(`[Cursor Hook] ${scriptName} skipped: ${error.message}\n`); + return null; } } function hookEnabled(hookId, allowedProfiles = ['standard', 'strict']) { - const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').toLowerCase(); - const profile = ['minimal', 'standard', 'strict'].includes(rawProfile) ? rawProfile : 'standard'; - - const disabled = new Set( - String(process.env.ECC_DISABLED_HOOKS || '') - .split(',') - .map(v => v.trim().toLowerCase()) - .filter(Boolean) - ); - - if (disabled.has(String(hookId || '').toLowerCase())) { - return false; + try { + const { isHookEnabled } = loadRuntimeModule('scripts', 'lib', 'hook-flags.js'); + return isHookEnabled(hookId, { profiles: allowedProfiles }); + } catch { + // Keep native Cursor checks controllable in partial or manually copied installs. + return fallbackHookEnabled(hookId, allowedProfiles); } - - return allowedProfiles.includes(profile); } -module.exports = { readStdin, getPluginRoot, transformToClaude, runExistingHook, hookEnabled }; +module.exports = { + readStdin, + getPluginRoot, + resolveRuntimePath, + loadRuntimeModule, + fallbackHookEnabled, + transformToClaude, + normalizeSessionStartOutput, + mergeSessionStartOutputs, + createStopFormatTypecheckOptions, + runExistingHook, + hookEnabled, +}; diff --git a/.cursor/hooks/after-file-edit.js b/.cursor/hooks/after-file-edit.js index 596435299..098905afa 100644 --- a/.cursor/hooks/after-file-edit.js +++ b/.cursor/hooks/after-file-edit.js @@ -4,16 +4,22 @@ readStdin().then(raw => { try { const input = JSON.parse(raw); const claudeInput = transformToClaude(input, { - tool_input: { file_path: input.path || input.file || '' } + tool_input: { file_path: input.file_path || input.path || input.file || '' } }); const claudeStr = JSON.stringify(claudeInput); - // Accumulate edited paths for batch format+typecheck at stop time - runExistingHook('post-edit-accumulator.js', claudeStr); - runExistingHook('post-edit-console-warn.js', claudeStr); - if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) { - runExistingHook('design-quality-check.js', claudeStr); + // Accumulate edited paths for batch format+typecheck at stop time. + if (hookEnabled('post:edit:accumulator', ['standard', 'strict'])) { + runExistingHook('post-edit-accumulator.js', claudeStr); } - } catch {} + if (hookEnabled('post:edit:console-warn', ['standard', 'strict'])) { + runExistingHook('post-edit-console-warn.js', claudeStr, { forwardStderr: true }); + } + if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) { + runExistingHook('design-quality-check.js', claudeStr, { forwardStderr: true }); + } + } catch { + // Cursor post-event hooks fail open on malformed or partial payloads. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/after-mcp-execution.js b/.cursor/hooks/after-mcp-execution.js index d09a780ed..de4bb40d2 100644 --- a/.cursor/hooks/after-mcp-execution.js +++ b/.cursor/hooks/after-mcp-execution.js @@ -1,12 +1,18 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('post:mcp:audit', ['standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const server = input.server || input.mcp_server || 'unknown'; const tool = input.tool || input.mcp_tool || 'unknown'; const success = input.error ? 'FAILED' : 'OK'; console.error(`[ECC] MCP result: ${server}/${tool} - ${success}`); - } catch {} + } catch { + // Audit-only hook: malformed payloads must not interrupt the agent loop. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/after-tab-file-edit.js b/.cursor/hooks/after-tab-file-edit.js index 355876c05..3dcb5e2dd 100644 --- a/.cursor/hooks/after-tab-file-edit.js +++ b/.cursor/hooks/after-tab-file-edit.js @@ -1,12 +1,18 @@ #!/usr/bin/env node -const { readStdin, runExistingHook, transformToClaude } = require('./adapter'); +const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('post:tab-edit:format', ['standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const claudeInput = transformToClaude(input, { - tool_input: { file_path: input.path || input.file || '' } + tool_input: { file_path: input.file_path || input.path || input.file || '' } }); runExistingHook('post-edit-format.js', JSON.stringify(claudeInput)); - } catch {} + } catch { + // Formatting is advisory, so malformed payloads fail open. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/before-mcp-execution.js b/.cursor/hooks/before-mcp-execution.js index 38c56c249..79045627b 100644 --- a/.cursor/hooks/before-mcp-execution.js +++ b/.cursor/hooks/before-mcp-execution.js @@ -1,11 +1,17 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('pre:mcp:audit', ['standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const server = input.server || input.mcp_server || 'unknown'; const tool = input.tool || input.mcp_tool || 'unknown'; console.error(`[ECC] MCP invocation: ${server}/${tool}`); - } catch {} + } catch { + // Audit-only hook: malformed payloads must not interrupt the agent loop. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/before-read-file.js b/.cursor/hooks/before-read-file.js index e221530c3..9ecbcaada 100644 --- a/.cursor/hooks/before-read-file.js +++ b/.cursor/hooks/before-read-file.js @@ -1,13 +1,20 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('pre:read:sensitive-file-warning', ['minimal', 'standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); - const filePath = input.path || input.file || ''; + const filePath = [input.file_path, input.path, input.file] + .find(value => typeof value === 'string' && value.trim()) || ''; if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) { console.error('[ECC] WARNING: Reading sensitive file: ' + filePath); console.error('[ECC] Ensure this data is not exposed in outputs'); } - } catch {} + } catch { + // Preserve the existing fail-open behavior for malformed Cursor payloads. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/before-shell-execution-block-no-verify.js b/.cursor/hooks/before-shell-execution-block-no-verify.js deleted file mode 100644 index 4c9e07db6..000000000 --- a/.cursor/hooks/before-shell-execution-block-no-verify.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -/** - * Cursor wrapper for block-no-verify. - * - * Cursor hooks previously called `npx block-no-verify@1.1.2`, an external - * package whose matcher over-matches: it blocks legitimate `git commit` - * whenever the literal string `--no-verify` (or `no-verify`) appears - * anywhere in the command string, including inside the commit message - * body. See issue #2107. - * - * The Claude Code surface already routes through the local, in-repo hook - * `scripts/hooks/block-no-verify.js`, which performs flag-position-aware - * tokenisation (skipping the value of `-m`, `-F`, `-am "..."`, etc.) and - * passes 25 regression tests covering every false-positive case. - * - * This wrapper gives Cursor the same matcher: read Cursor stdin, transform - * to the Claude Code `tool_input.command` shape the local hook understands, - * delegate to its exported `run()`, then forward the exit code and stderr. - */ - -'use strict'; - -const { readStdin, hookEnabled } = require('./adapter'); -const { run } = require('../../scripts/hooks/block-no-verify'); - -readStdin() - .then(raw => { - if (!hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) { - process.stdout.write(raw); - return; - } - - let command = ''; - try { - const parsed = JSON.parse(raw || '{}'); - command = String(parsed.command || parsed.args?.command || ''); - } catch { - command = String(raw || ''); - } - - // Local hook accepts either the raw command string or a Claude-Code - // shaped `{ tool_input: { command } }` JSON. Pass the Claude shape so - // the JSON branch in extractCommand() is exercised the same way the - // Claude Code surface exercises it — keeps the two surfaces on the - // same code path. - const claudeInput = JSON.stringify({ tool_input: { command } }); - const result = run(claudeInput); - - if (result && result.exitCode === 2) { - if (result.stderr) { - process.stderr.write(String(result.stderr) + '\n'); - } - process.exit(2); - } - - process.stdout.write(raw); - }) - .catch(() => { - // Per repo rule: hooks must exit 0 on non-critical errors and never - // unexpectedly block tool execution. A parse / transport error here - // is non-critical — fall through. - process.exit(0); - }); diff --git a/.cursor/hooks/before-shell-execution.js b/.cursor/hooks/before-shell-execution.js index fbcf8e0a1..bee100908 100644 --- a/.cursor/hooks/before-shell-execution.js +++ b/.cursor/hooks/before-shell-execution.js @@ -1,23 +1,56 @@ #!/usr/bin/env node -const { readStdin, hookEnabled } = require('./adapter'); -const { splitShellSegments } = require('../../scripts/lib/shell-split'); +const { + readStdin, + hookEnabled, + runExistingHook, + transformToClaude, +} = require('./adapter'); + +readStdin({ includeMetadata: true }) + .then(({ raw, truncated, readError }) => { + if (truncated || readError) { + const hasBlockingGuard = ( + hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict']) + || ( + process.platform !== 'win32' + && hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) + ) + ); + if (hasBlockingGuard) { + console.error(truncated + ? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeShellExecution' + : '[Cursor Hook] stdin read failed; blocking beforeShellExecution'); + process.exit(2); + } + console.error(truncated + ? '[Cursor Hook] stdin exceeded the safety limit; suppressing truncated input' + : '[Cursor Hook] stdin read failed; suppressing untrusted input'); + return; + } -readStdin() - .then(raw => { try { - const input = JSON.parse(raw || '{}'); - const cmd = String(input.command || input.args?.command || ''); + let input; + let cmd; + try { + input = JSON.parse(raw || '{}'); + cmd = String(input.command || input.args?.command || ''); + } catch { + input = {}; + cmd = String(raw || ''); + } + const claudeInput = transformToClaude(input, { + tool_input: { command: cmd }, + }); + const claudeRaw = JSON.stringify(claudeInput); + + // Cursor may execute only the first hook registered for an event. Keep + // every blocking shell guard behind this single event-level dispatcher. + if (hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) { + runExistingHook('block-no-verify.js', claudeRaw); + } if (hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) && process.platform !== 'win32') { - const segments = splitShellSegments(cmd); - const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; - const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/; - const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment)); - if (hasBlockedDev) { - console.error('[ECC] BLOCKED: Dev server must run in tmux for log access'); - console.error('[ECC] Use: tmux new-session -d -s dev "npm run dev"'); - process.exit(2); - } + runExistingHook('pre-bash-dev-server-block.js', claudeRaw); } if ( diff --git a/.cursor/hooks/before-submit-prompt.js b/.cursor/hooks/before-submit-prompt.js index f48426ae5..f2888667b 100644 --- a/.cursor/hooks/before-submit-prompt.js +++ b/.cursor/hooks/before-submit-prompt.js @@ -1,6 +1,10 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('pre:prompt:secret-warning', ['minimal', 'standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const prompt = input.prompt || input.content || input.message || ''; @@ -18,6 +22,8 @@ readStdin().then(raw => { break; } } - } catch {} + } catch { + // This warning hook remains advisory when Cursor sends malformed input. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/before-tab-file-read.js b/.cursor/hooks/before-tab-file-read.js index f4ea79a49..2fcf1d079 100644 --- a/.cursor/hooks/before-tab-file-read.js +++ b/.cursor/hooks/before-tab-file-read.js @@ -1,13 +1,26 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); -readStdin().then(raw => { +const { readStdin, hookEnabled } = require('./adapter'); +readStdin({ includeMetadata: true }).then(({ raw, truncated, readError }) => { + if (!hookEnabled('pre:tab-read:sensitive-file-block', ['minimal', 'standard', 'strict'])) { + process.stdout.write(raw); + return; + } + if (truncated || readError) { + console.error(truncated + ? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeTabFileRead' + : '[Cursor Hook] stdin read failed; blocking beforeTabFileRead'); + process.exit(2); + } try { const input = JSON.parse(raw); - const filePath = input.path || input.file || ''; + const filePath = [input.file_path, input.path, input.file] + .find(value => typeof value === 'string' && value.trim()) || ''; if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) { console.error('[ECC] BLOCKED: Tab cannot read sensitive file: ' + filePath); process.exit(2); } - } catch {} + } catch { + // Preserve the existing fail-open behavior for malformed Cursor payloads. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/pre-compact.js b/.cursor/hooks/pre-compact.js index 1b2156e1d..1e4f1e503 100644 --- a/.cursor/hooks/pre-compact.js +++ b/.cursor/hooks/pre-compact.js @@ -1,7 +1,13 @@ #!/usr/bin/env node -const { readStdin, runExistingHook, transformToClaude } = require('./adapter'); +const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter'); readStdin().then(raw => { - const claudeInput = JSON.parse(raw || '{}'); - runExistingHook('pre-compact.js', transformToClaude(claudeInput)); + try { + const claudeInput = JSON.parse(raw || '{}'); + if (hookEnabled('pre:compact', ['standard', 'strict'])) { + runExistingHook('pre-compact.js', transformToClaude(claudeInput)); + } + } catch { + // PreCompact is advisory; malformed payloads fail open. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/session-start.js b/.cursor/hooks/session-start.js index b3626af2e..3bb3b2adc 100644 --- a/.cursor/hooks/session-start.js +++ b/.cursor/hooks/session-start.js @@ -1,10 +1,64 @@ #!/usr/bin/env node -const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter'); +const { + hookEnabled, + mergeSessionStartOutputs, + normalizeSessionStartOutput, + readStdin, + runExistingHook, + transformToClaude, +} = require('./adapter'); readStdin().then(raw => { - const input = JSON.parse(raw || '{}'); - const claudeInput = transformToClaude(input); - if (hookEnabled('session:start', ['minimal', 'standard', 'strict'])) { - runExistingHook('session-start.js', claudeInput); + let input = {}; + try { + const parsed = JSON.parse(raw || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + input = parsed; + } + } catch { + // The Cursor env helper handles malformed input and supplies a safe default. } - process.stdout.write(raw); + const transformedInput = transformToClaude(input); + const claudeInput = { + ...transformedInput, + hook_event_name: 'SessionStart', + source: 'startup', + }; + let outputs = []; + if (!hookEnabled('session:start', ['minimal', 'standard', 'strict'])) { + process.stdout.write(JSON.stringify({})); + return; + } + + const envResult = runExistingHook('cursor-session-env.js', raw, { + forwardStderr: true, + }); + if (envResult && envResult.status === 0 && envResult.stdout) { + try { + outputs = [...outputs, normalizeSessionStartOutput(envResult.stdout)]; + } catch { + console.error('[Cursor Hook] Ignoring invalid Cursor session environment output'); + } + } + + const sessionEnv = outputs[0]?.env || {}; + const sessionResult = runExistingHook('session-start.js', claudeInput, { + env: { + ...sessionEnv, + ...(transformedInput.session_id + ? { CLAUDE_SESSION_ID: transformedInput.session_id } + : {}), + }, + forwardStderr: true, + }); + if (sessionResult && sessionResult.status === 0 && sessionResult.stdout) { + try { + outputs = [ + ...outputs, + normalizeSessionStartOutput(sessionResult.stdout, { shared: true }), + ]; + } catch { + console.error('[Cursor Hook] Ignoring invalid shared SessionStart output'); + } + } + process.stdout.write(JSON.stringify(mergeSessionStartOutputs(outputs))); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/stop.js b/.cursor/hooks/stop.js index a6cd74c02..cca7a15f1 100644 --- a/.cursor/hooks/stop.js +++ b/.cursor/hooks/stop.js @@ -1,20 +1,41 @@ #!/usr/bin/env node -const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter'); +const { + createStopFormatTypecheckOptions, + hookEnabled, + readStdin, + runExistingHook, + transformToClaude, +} = require('./adapter'); readStdin().then(raw => { const input = JSON.parse(raw || '{}'); const claudeInput = transformToClaude(input); + const sharedOptions = { + env: claudeInput.session_id + ? { CLAUDE_SESSION_ID: claudeInput.session_id } + : {}, + }; - if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) { - runExistingHook('check-console-log.js', claudeInput); - } if (hookEnabled('stop:session-end', ['minimal', 'standard', 'strict'])) { - runExistingHook('session-end.js', claudeInput); + runExistingHook('session-end.js', claudeInput, sharedOptions); } if (hookEnabled('stop:evaluate-session', ['minimal', 'standard', 'strict'])) { - runExistingHook('evaluate-session.js', claudeInput); + runExistingHook('evaluate-session.js', claudeInput, sharedOptions); } if (hookEnabled('stop:cost-tracker', ['minimal', 'standard', 'strict'])) { - runExistingHook('cost-tracker.js', claudeInput); + runExistingHook('cost-tracker.js', claudeInput, sharedOptions); + } + if (hookEnabled('stop:format-typecheck', ['standard', 'strict'])) { + runExistingHook( + 'stop-format-typecheck.js', + claudeInput, + createStopFormatTypecheckOptions(sharedOptions.env) + ); + } + if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) { + runExistingHook('check-console-log.js', claudeInput, { + ...sharedOptions, + forwardStderr: true, + }); } process.stdout.write(raw); diff --git a/.cursor/hooks/subagent-start.js b/.cursor/hooks/subagent-start.js index c03c9cd50..a5012bcbf 100644 --- a/.cursor/hooks/subagent-start.js +++ b/.cursor/hooks/subagent-start.js @@ -1,10 +1,16 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('subagent:start:audit', ['standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const agent = input.agent_name || input.agent || 'unknown'; console.error(`[ECC] Agent spawned: ${agent}`); - } catch {} + } catch { + // Audit-only hook: malformed payloads must not interrupt the agent loop. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/.cursor/hooks/subagent-stop.js b/.cursor/hooks/subagent-stop.js index d6cf681b1..1d7da10b3 100644 --- a/.cursor/hooks/subagent-stop.js +++ b/.cursor/hooks/subagent-stop.js @@ -1,10 +1,16 @@ #!/usr/bin/env node -const { readStdin } = require('./adapter'); +const { readStdin, hookEnabled } = require('./adapter'); readStdin().then(raw => { + if (!hookEnabled('subagent:stop:audit', ['standard', 'strict'])) { + process.stdout.write(raw); + return; + } try { const input = JSON.parse(raw); const agent = input.agent_name || input.agent || 'unknown'; console.error(`[ECC] Agent completed: ${agent}`); - } catch {} + } catch { + // Audit-only hook: malformed payloads must not interrupt the agent loop. + } process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/README.md b/README.md index 6263bfb16..d0a7675b5 100644 --- a/README.md +++ b/README.md @@ -1290,7 +1290,7 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili |---|---|---|---| | Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. | | Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. | -| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). | +| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build; hook events use profile-gated dispatchers, but full Claude feature parity is not claimed. | | OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). | | GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. | | GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills and adapted agents in `.github/` | ECC hooks, rules, and command shims are not installed by this target. | @@ -1436,7 +1436,7 @@ ECC provides Cursor IDE support with hooks, rules, agents, skills, commands, and | Component | Count | Details | |-----------|-------|---------| | Hook Events | 15 | sessionStart, beforeShellExecution, afterFileEdit, beforeMCPExecution, beforeSubmitPrompt, and 10 more | -| Hook Scripts | 16 | Thin Node.js scripts delegating to `scripts/hooks/` via shared adapter | +| Hook Entrypoints | 15 | Thin Node.js event dispatchers delegating to `scripts/hooks/` via shared adapter | | Rules | 34 | 9 common (alwaysApply) + 25 language-specific (TypeScript, Python, Go, Swift, PHP) | | Agents | 48 | `.cursor/agents/ecc-*.md` when installed; prefixed to avoid collisions with user or marketplace agents | | Skills | Shared + Bundled | `.cursor/skills/` for translated additions | @@ -1470,7 +1470,7 @@ Continuous learning v2 instincts remain separate under `CLV2_HOMUNCULUS_DIR` (de #### Hook architecture (DRY adapter pattern) -Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. +Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. Each Cursor event is registered with one event-level dispatcher so enabled checks run in a deterministic order and remain compatible with Cursor versions that execute only the first matching entry. The adapter resolves the shared runtime from both the ECC checkout and an installed project's `.cursor/scripts/` tree. ``` Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js @@ -1478,8 +1478,8 @@ Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js ``` Key hooks: -- **beforeShellExecution**: Blocks dev servers outside tmux (exit 2), git push review -- **afterFileEdit**: Auto-format + TypeScript check + console.log warning +- **beforeShellExecution**: Blocks git hook bypasses and dev servers outside tmux (exit 2), then adds profile-gated shell reminders +- **afterFileEdit / stop**: Accumulates edited JS/TS files and runs one profile-gated format/typecheck batch at Stop, alongside the console.log audit - **beforeSubmitPrompt**: Detects secrets (sk-, ghp_, AKIA patterns) in prompts - **beforeTabFileRead**: Blocks Tab from reading .env, .key, .pem files (exit 2) - **beforeMCPExecution / afterMCPExecution**: MCP audit logging diff --git a/scaffolds/cursor/hooks.json b/scaffolds/cursor/hooks.json index f4e389e8a..83af056fd 100644 --- a/scaffolds/cursor/hooks.json +++ b/scaffolds/cursor/hooks.json @@ -3,7 +3,7 @@ "hooks": { "sessionStart": [ { - "command": "node .cursor/scripts/hooks/cursor-session-env.js" + "command": "node .cursor/hooks/session-start.js" } ] } diff --git a/scripts/hooks/stop-format-typecheck.js b/scripts/hooks/stop-format-typecheck.js index 8ae580db5..d6e002cf4 100644 --- a/scripts/hooks/stop-format-typecheck.js +++ b/scripts/hooks/stop-format-typecheck.js @@ -26,7 +26,23 @@ const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../li const MAX_STDIN = 1024 * 1024; // Total ms budget reserved for all batches (leaves headroom below the 300s Stop timeout) -const TOTAL_BUDGET_MS = 270_000; +const DEFAULT_TOTAL_BUDGET_MS = 270_000; +const MAX_TOTAL_BUDGET_MS = 270_000; + +function getTotalBudgetMs(env = process.env) { + const raw = String(env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS || '').trim(); + if (!/^\d+$/.test(raw)) return DEFAULT_TOTAL_BUDGET_MS; + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed > 0 + ? Math.min(parsed, MAX_TOTAL_BUDGET_MS) + : DEFAULT_TOTAL_BUDGET_MS; +} + +function getPerBatchBudgetMs(totalBudgetMs, totalBatches) { + return totalBatches > 0 + ? Math.max(1, Math.floor(totalBudgetMs / totalBatches)) + : 60_000; +} // Characters cmd.exe treats as separators/operators when shell: true is used. // Includes spaces and parentheses to guard paths like "C:\Users\John Doe\...". @@ -197,7 +213,8 @@ function main() { // Distribute the budget evenly across all batches so the cumulative total // stays within the Stop hook wall-clock limit even in large monorepos. const totalBatches = byProjectRoot.size + byTsConfigDir.size; - const perBatchMs = totalBatches > 0 ? Math.floor(TOTAL_BUDGET_MS / totalBatches) : 60_000; + const totalBudgetMs = getTotalBudgetMs(); + const perBatchMs = getPerBatchBudgetMs(totalBudgetMs, totalBatches); for (const [root, batch] of byProjectRoot) formatBatch(root, batch, perBatchMs); for (const [tsDir, batch] of byTsConfigDir) typecheckBatch(tsDir, batch, perBatchMs); @@ -248,4 +265,10 @@ if (require.main === module) { }); } -module.exports = { run, parseAccumulator, isPluginClonePath }; +module.exports = { + run, + parseAccumulator, + isPluginClonePath, + getTotalBudgetMs, + getPerBatchBudgetMs, +}; diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 81e71aefd..e77c5c278 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -88,15 +88,19 @@ module.exports = createInstallTargetAdapter({ })); }).sort((left, right) => { const getPriority = value => { - if (value === '.cursor') { + if (value === 'hooks') { return 0; } - if (value === 'rules') { + if (value === '.cursor') { return 1; } - return 2; + if (value === 'rules') { + return 2; + } + + return 3; }; const leftPriority = getPriority(left.sourceRelativePath); @@ -161,6 +165,23 @@ module.exports = createInstallTargetAdapter({ })); } + if (sourceRelativePath === 'hooks') { + return takeUniqueOperations([ + createManagedOperation({ + moduleId: module.id, + sourceRelativePath: path.join('.cursor', 'hooks'), + destinationPath: path.join(targetRoot, 'hooks'), + strategy: 'preserve-relative-path', + }), + createManagedOperation({ + moduleId: module.id, + sourceRelativePath: path.join('.cursor', 'hooks.json'), + destinationPath: path.join(targetRoot, 'hooks.json'), + strategy: 'preserve-relative-path', + }), + ]); + } + if (sourceRelativePath === '.cursor') { const cursorRoot = path.join(repoRoot, '.cursor'); if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) { @@ -169,7 +190,7 @@ module.exports = createInstallTargetAdapter({ const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) - .filter(entry => entry.name !== 'rules') + .filter(entry => !['rules', 'hooks', 'hooks.json'].includes(entry.name)) .map(entry => createManagedOperation({ moduleId: module.id, sourceRelativePath: path.join('.cursor', entry.name), diff --git a/tests/hooks/cursor-block-no-verify.test.js b/tests/hooks/cursor-block-no-verify.test.js index a1e7f1277..921255a1c 100644 --- a/tests/hooks/cursor-block-no-verify.test.js +++ b/tests/hooks/cursor-block-no-verify.test.js @@ -1,9 +1,9 @@ /** - * Tests for .cursor/hooks/before-shell-execution-block-no-verify.js + * Tests for block-no-verify behavior in the Cursor beforeShell dispatcher. * * Issue #2107: previously .cursor/hooks.json wired `npx block-no-verify@1.1.2`, * which over-matches and blocks legitimate commits whose message body - * mentions `--no-verify` or `-n`. The wrapper added in this PR delegates + * mentions `--no-verify` or `-n`. The event-level dispatcher delegates * to the local scripts/hooks/block-no-verify.js so Cursor users get the * same flag-position-aware matcher Claude Code already uses. */ @@ -14,17 +14,23 @@ const assert = require('assert'); const path = require('path'); const { spawnSync } = require('child_process'); -const wrapper = path.join( +const dispatcher = path.join( __dirname, '..', '..', - '.cursor', 'hooks', 'before-shell-execution-block-no-verify.js' + '.cursor', 'hooks', 'before-shell-execution.js' ); function runWrapper(input, env = {}) { const rawInput = typeof input === 'string' ? input : JSON.stringify(input); - const result = spawnSync('node', [wrapper], { + const result = spawnSync('node', [dispatcher], { input: rawInput, encoding: 'utf8', - env: { ...process.env, ECC_HOOK_PROFILE: 'standard', ...env }, + env: { + ...process.env, + ECC_HOOKS_ENABLED: 'true', + ECC_HOOK_PROFILE: 'standard', + ECC_DISABLED_HOOKS: 'pre:bash:dev-server-block', + ...env, + }, timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -51,7 +57,7 @@ function test(name, fn) { let passed = 0; let failed = 0; -console.log('\ncursor block-no-verify wrapper tests'); +console.log('\ncursor block-no-verify dispatcher tests'); console.log('─'.repeat(50)); // --- Cursor input shapes --- @@ -129,6 +135,12 @@ if (test('handles malformed JSON gracefully (treats raw as command string)', () assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`); })) passed++; else failed++; +if (test('blocks a bypass attempt supplied as a raw command string', () => { + const r = runWrapper('git commit --no-verify -m "msg"'); + assert.strictEqual(r.code, 2, `expected 2, got ${r.code}: ${r.stderr}`); + assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`); +})) passed++; else failed++; + // --- Disable via ECC_DISABLED_HOOKS --- if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => { @@ -136,7 +148,7 @@ if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => { { command: 'git commit --no-verify -m "msg"' }, { ECC_DISABLED_HOOKS: 'pre:bash:block-no-verify' } ); - // When the hook is disabled, the wrapper should pass through (exit 0) + // When the hook is disabled, the dispatcher should pass through (exit 0) // even on a real bypass attempt. assert.strictEqual(r.code, 0, `expected 0 when disabled, got ${r.code}: ${r.stderr}`); })) passed++; else failed++; diff --git a/tests/hooks/cursor-hook-dispatchers.test.js b/tests/hooks/cursor-hook-dispatchers.test.js new file mode 100644 index 000000000..22a89be3d --- /dev/null +++ b/tests/hooks/cursor-hook-dispatchers.test.js @@ -0,0 +1,699 @@ +/** + * Contract tests for Cursor's event-level hook dispatchers. + * + * Issue #2419: Cursor installs must expose one command per event, preserve + * blocking exit codes while dispatching multiple checks, and resolve shared + * runtime modules from the installed .cursor/ tree. + */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOKS_CONFIG = path.join(REPO_ROOT, '.cursor', 'hooks.json'); +const BEFORE_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-shell-execution.js'); +const STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'stop.js'); +const BEFORE_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-mcp-execution.js'); +const AFTER_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-mcp-execution.js'); +const AFTER_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-shell-execution.js'); +const AFTER_TAB_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-tab-file-edit.js'); +const BEFORE_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-read-file.js'); +const BEFORE_PROMPT = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-submit-prompt.js'); +const BEFORE_TAB_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-tab-file-read.js'); +const PRE_COMPACT = path.join(REPO_ROOT, '.cursor', 'hooks', 'pre-compact.js'); +const SUBAGENT_START = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-start.js'); +const SUBAGENT_STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-stop.js'); +const AFTER_FILE_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-file-edit.js'); +const { createStopFormatTypecheckOptions } = require('../../.cursor/hooks/adapter'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` ${error.message}`); + return false; + } +} + +function runHook(scriptPath, input, env = {}, cwd = REPO_ROOT) { + const raw = typeof input === 'string' ? input : JSON.stringify(input); + const childEnv = { + ...process.env, + ECC_HOOK_PROFILE: 'standard', + ...env, + }; + if (!Object.hasOwn(env, 'ECC_HOOKS_ENABLED')) { + delete childEnv.ECC_HOOKS_ENABLED; + } + if (!Object.hasOwn(env, 'ECC_DISABLED_HOOKS')) { + delete childEnv.ECC_DISABLED_HOOKS; + } + if (!Object.hasOwn(env, 'CLAUDE_SESSION_ID')) { + delete childEnv.CLAUDE_SESSION_ID; + } + if (!Object.hasOwn(env, 'ECC_SESSION_ID')) { + delete childEnv.ECC_SESSION_ID; + } + for (const key of [ + 'CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED', + 'CLAUDE_PLUGIN_OPTION_HOOK_PROFILE', + 'ECC_HOOK_CONFIG', + 'CLAUDE_PLUGIN_ROOT', + 'ECC_PLUGIN_ROOT', + ]) { + if (!Object.hasOwn(env, key)) { + delete childEnv[key]; + } + } + return spawnSync(process.execPath, [scriptPath], { + cwd, + env: childEnv, + input: raw, + encoding: 'utf8', + timeout: 30000, + }); +} + +function accumulatorPath(sessionId, cwd) { + const raw = sessionId + || crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12); + const safeId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); + return path.join(os.tmpdir(), `ecc-edited-${safeId}.txt`); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function createInstalledCursorFixture(options = {}) { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-dispatcher-')); + const cursorRoot = path.join(projectRoot, '.cursor'); + const hooksRoot = path.join(cursorRoot, 'hooks'); + const scriptsRoot = path.join(cursorRoot, 'scripts'); + + fs.mkdirSync(hooksRoot, { recursive: true }); + for (const fileName of [ + 'adapter.js', + 'after-file-edit.js', + 'before-shell-execution.js', + 'session-start.js', + 'stop.js', + ]) { + fs.copyFileSync( + path.join(REPO_ROOT, '.cursor', 'hooks', fileName), + path.join(hooksRoot, fileName) + ); + } + + fs.mkdirSync(path.join(scriptsRoot, 'lib'), { recursive: true }); + if (options.includeHookFlags !== false) { + fs.copyFileSync( + path.join(REPO_ROOT, 'scripts', 'lib', 'hook-flags.js'), + path.join(scriptsRoot, 'lib', 'hook-flags.js') + ); + } + writeFile( + path.join(scriptsRoot, 'lib', 'shell-split.js'), + "module.exports = { splitShellSegments(value) { return [value]; } };\n" + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'block-no-verify.js'), + [ + "'use strict';", + "const fs = require('fs');", + "const command = JSON.parse(fs.readFileSync(0, 'utf8')).tool_input.command;", + "if (command.includes('--no-verify')) {", + " process.stderr.write('BLOCKED: installed runtime\\n');", + ' process.exit(2);', + '}', + '', + ].join('\n') + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'pre-bash-dev-server-block.js'), + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n" + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'stop-format-typecheck.js'), + [ + "'use strict';", + "const fs = require('fs');", + "fs.appendFileSync(process.env.CURSOR_HOOK_LOG, `stop-format-typecheck:${process.env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS}\\n`);", + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));", + '', + ].join('\n') + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'session-end.js'), + [ + "'use strict';", + "const fs = require('fs');", + "fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'session-end\\n');", + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));", + '', + ].join('\n') + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'post-edit-accumulator.js'), + [ + "'use strict';", + "const fs = require('fs');", + "fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'post-edit-accumulator\\n');", + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));", + '', + ].join('\n') + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'post-edit-console-warn.js'), + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n" + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'design-quality-check.js'), + "process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n" + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'cursor-session-env.js'), + "process.stdout.write(JSON.stringify({ env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' }, additional_context: 'cursor env' }));\n" + ); + writeFile( + path.join(scriptsRoot, 'hooks', 'session-start.js'), + [ + "const fs = require('fs');", + "const input = JSON.parse(fs.readFileSync(0, 'utf8'));", + "if (input.hook_event_name !== 'SessionStart' || input.source !== 'startup') process.exit(3);", + "if (process.env.ECC_AGENT_DATA_HOME !== '/tmp/cursor-ecc') process.exit(4);", + "process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'shared context' } }));", + '', + ].join('\n') + ); + + return { + projectRoot, + beforeShell: path.join(hooksRoot, 'before-shell-execution.js'), + afterFileEdit: path.join(hooksRoot, 'after-file-edit.js'), + sessionStart: path.join(hooksRoot, 'session-start.js'), + stop: path.join(hooksRoot, 'stop.js'), + logPath: path.join(projectRoot, 'hook.log'), + }; +} + +let passed = 0; +let failed = 0; + +console.log('\nCursor hook dispatcher contract'); +console.log('-'.repeat(55)); + +if (test('registers at most one command for every Cursor event', () => { + const config = JSON.parse(fs.readFileSync(HOOKS_CONFIG, 'utf8')); + for (const [eventName, entries] of Object.entries(config.hooks)) { + assert.strictEqual( + entries.length, + 1, + `${eventName} must use one event-level dispatcher` + ); + } + assert.strictEqual( + config.hooks.beforeShellExecution[0].command, + 'node .cursor/hooks/before-shell-execution.js' + ); +})) passed++; else failed++; + +if (test('Cursor sessionStart scaffold points at the event dispatcher', () => { + const scaffold = JSON.parse(fs.readFileSync( + path.join(REPO_ROOT, 'scaffolds', 'cursor', 'hooks.json'), + 'utf8' + )); + assert.deepStrictEqual(scaffold.hooks.sessionStart, [{ + command: 'node .cursor/hooks/session-start.js', + }]); +})) passed++; else failed++; + +if (test('beforeShellExecution dispatcher blocks git hook bypass attempts', () => { + const result = runHook(BEFORE_SHELL, { command: 'git commit --no-verify -m test' }); + assert.strictEqual(result.status, 2, result.stderr); + assert.match(result.stderr, /BLOCKED/); +})) passed++; else failed++; + +if (test('beforeShellExecution dispatcher honors the master hook switch', () => { + const result = runHook( + BEFORE_SHELL, + { command: 'git commit --no-verify -m test' }, + { ECC_HOOKS_ENABLED: 'false' } + ); + assert.strictEqual(result.status, 0, result.stderr); +})) passed++; else failed++; + +if (test('beforeShellExecution dispatcher preserves the dev-server guard', () => { + if (process.platform === 'win32') { + console.log(' (tmux guard is not supported on Windows; skipping)'); + return; + } + const result = runHook(BEFORE_SHELL, { command: 'npm run dev' }); + assert.strictEqual(result.status, 2, result.stderr); + assert.match(result.stderr, /Dev server must run in tmux/); +})) passed++; else failed++; + +if (test('Cursor-native security hooks honor the master hook switch', () => { + const disabledEnv = { ECC_HOOKS_ENABLED: 'false' }; + const cases = [ + [BEFORE_READ, { path: '.env' }], + [BEFORE_PROMPT, { prompt: 'token sk-123456789012345678901234' }], + [BEFORE_TAB_READ, { path: 'credentials.pem' }], + ]; + for (const [scriptPath, input] of cases) { + const result = runHook(scriptPath, input, disabledEnv); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`); + } +})) passed++; else failed++; + +if (test('Cursor-sensitive file hooks recognize the official file_path field', () => { + const warning = runHook(BEFORE_READ, { file_path: '.env' }); + assert.strictEqual(warning.status, 0, warning.stderr); + assert.match(warning.stderr, /Reading sensitive file/); + + const blocked = runHook(BEFORE_TAB_READ, { file_path: 'credentials.pem' }); + assert.strictEqual(blocked.status, 2, blocked.stderr); + assert.match(blocked.stderr, /BLOCKED/); +})) passed++; else failed++; + +if (test('invalid path field types cannot shadow a valid string fallback', () => { + const input = { file_path: {}, path: '.env' }; + const warning = runHook(BEFORE_READ, input); + assert.strictEqual(warning.status, 0, warning.stderr); + assert.match(warning.stderr, /Reading sensitive file/); + + const blocked = runHook(BEFORE_TAB_READ, input); + assert.strictEqual(blocked.status, 2, blocked.stderr); + assert.match(blocked.stderr, /BLOCKED/); +})) passed++; else failed++; + +if (test('Cursor-native audit hooks stay disabled in the minimal profile', () => { + const cases = [ + [BEFORE_MCP, { server: 'example', tool: 'read' }], + [AFTER_MCP, { server: 'example', tool: 'read' }], + [AFTER_SHELL, { command: 'gh pr create', output: 'https://github.com/a/b/pull/1' }], + [AFTER_TAB_EDIT, { path: 'src/app.js' }], + [PRE_COMPACT, {}], + [SUBAGENT_START, { agent_name: 'explore' }], + [SUBAGENT_STOP, { agent_name: 'explore' }], + ]; + for (const [scriptPath, input] of cases) { + const result = runHook(scriptPath, input, { ECC_HOOK_PROFILE: 'minimal' }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`); + } +})) passed++; else failed++; + +if (test('disabled preCompact preserves malformed input unchanged', () => { + const raw = '{not-json'; + const result = runHook(PRE_COMPACT, raw, { ECC_HOOK_PROFILE: 'minimal' }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, raw); +})) passed++; else failed++; + +if (test('Stop dispatcher runs batch format/typecheck in standard profile', () => { + const sessionId = `cursor-stop-standard-${process.pid}`; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-stop-')); + const editedFile = path.join(tempRoot, 'edited.js'); + const accumFile = accumulatorPath(sessionId, tempRoot); + writeFile(editedFile, 'const answer = 42;\n'); + writeFile(accumFile, `${editedFile}\n`); + + try { + const result = runHook(STOP, { conversation_id: sessionId }, { + CLAUDE_SESSION_ID: sessionId, + ECC_DISABLED_HOOKS: [ + 'stop:check-console-log', + 'stop:session-end', + 'stop:evaluate-session', + 'stop:cost-tracker', + ].join(','), + }, tempRoot); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!fs.existsSync(accumFile), 'Stop should consume the edit accumulator'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(accumFile, { force: true }); + } +})) passed++; else failed++; + +if (test('afterFileEdit maps Cursor file_path and conversation_id', () => { + const conversationId = `cursor-edit-${process.pid}`; + const accumFile = accumulatorPath(conversationId, REPO_ROOT); + fs.rmSync(accumFile, { force: true }); + + try { + const editedFile = path.join(REPO_ROOT, 'src', 'cursor-edit.js'); + const result = runHook(AFTER_FILE_EDIT, { + conversation_id: conversationId, + file_path: editedFile, + }, { + ECC_DISABLED_HOOKS: [ + 'post:edit:console-warn', + 'post:edit:design-quality-check', + ].join(','), + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(fs.readFileSync(accumFile, 'utf8'), `${editedFile}\n`); + } finally { + fs.rmSync(accumFile, { force: true }); + } +})) passed++; else failed++; + +if (test('parallel Cursor conversations keep separate edit accumulators', () => { + const firstId = `cursor-first-${process.pid}`; + const secondId = `cursor-second-${process.pid}`; + const firstAccum = accumulatorPath(firstId, REPO_ROOT); + const secondAccum = accumulatorPath(secondId, REPO_ROOT); + fs.rmSync(firstAccum, { force: true }); + fs.rmSync(secondAccum, { force: true }); + + try { + const env = { + ECC_DISABLED_HOOKS: [ + 'post:edit:console-warn', + 'post:edit:design-quality-check', + ].join(','), + }; + assert.strictEqual(runHook(AFTER_FILE_EDIT, { + conversation_id: firstId, + file_path: path.join(REPO_ROOT, 'src', 'first.js'), + }, env).status, 0); + assert.strictEqual(runHook(AFTER_FILE_EDIT, { + conversation_id: secondId, + file_path: path.join(REPO_ROOT, 'src', 'second.js'), + }, env).status, 0); + + assert.ok(fs.existsSync(firstAccum), 'First conversation should own an accumulator'); + assert.ok(fs.existsSync(secondAccum), 'Second conversation should own an accumulator'); + + const stopped = runHook(STOP, { conversation_id: firstId }, { + ECC_DISABLED_HOOKS: [ + 'stop:check-console-log', + 'stop:session-end', + 'stop:evaluate-session', + 'stop:cost-tracker', + ].join(','), + }); + assert.strictEqual(stopped.status, 0, stopped.stderr); + assert.ok(!fs.existsSync(firstAccum), 'First Stop should consume only its accumulator'); + assert.ok(fs.existsSync(secondAccum), 'Second conversation accumulator must remain'); + } finally { + fs.rmSync(firstAccum, { force: true }); + fs.rmSync(secondAccum, { force: true }); + } +})) passed++; else failed++; + +if (test('afterFileEdit forwards user-visible console warnings', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-warning-')); + const editedFile = path.join(tempRoot, 'warning.js'); + writeFile(editedFile, 'console.log("debug");\n'); + + try { + const result = runHook(AFTER_FILE_EDIT, { file_path: editedFile }, { + ECC_DISABLED_HOOKS: [ + 'post:edit:accumulator', + 'post:edit:design-quality-check', + ].join(','), + }, tempRoot); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stderr, /console\.log found/); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('Stop reserves time for lifecycle hooks before the format batch', () => { + const fixture = createInstalledCursorFixture(); + try { + const result = runHook(fixture.stop, { conversation_id: 'ordered-stop' }, { + CURSOR_HOOK_LOG: fixture.logPath, + ECC_DISABLED_HOOKS: [ + 'stop:check-console-log', + 'stop:evaluate-session', + 'stop:cost-tracker', + ].join(','), + }, fixture.projectRoot); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual( + fs.readFileSync(fixture.logPath, 'utf8').trim().split('\n'), + ['session-end', 'stop-format-typecheck:210000'] + ); + assert.deepStrictEqual(createStopFormatTypecheckOptions({ CLAUDE_SESSION_ID: 'abc' }), { + timeout: 225000, + forwardStderr: true, + env: { + CLAUDE_SESSION_ID: 'abc', + ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000', + }, + }); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('Stop dispatcher leaves batch format/typecheck disabled in minimal profile', () => { + const sessionId = `cursor-stop-minimal-${process.pid}`; + const accumFile = accumulatorPath(sessionId, REPO_ROOT); + writeFile(accumFile, `${path.join(REPO_ROOT, 'README.md')}\n`); + + try { + const result = runHook(STOP, { conversation_id: sessionId }, { + CLAUDE_SESSION_ID: sessionId, + ECC_HOOK_PROFILE: 'minimal', + ECC_DISABLED_HOOKS: [ + 'stop:session-end', + 'stop:evaluate-session', + 'stop:cost-tracker', + ].join(','), + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(fs.existsSync(accumFile), 'Minimal profile must not run format/typecheck'); + } finally { + fs.rmSync(accumFile, { force: true }); + } +})) passed++; else failed++; + +if (test('afterFileEdit does not accumulate files in the minimal profile', () => { + const sessionId = `cursor-edit-minimal-${process.pid}`; + const accumFile = accumulatorPath(sessionId, REPO_ROOT); + fs.rmSync(accumFile, { force: true }); + + try { + const result = runHook( + AFTER_FILE_EDIT, + { path: path.join(REPO_ROOT, 'example.js') }, + { CLAUDE_SESSION_ID: sessionId, ECC_HOOK_PROFILE: 'minimal' } + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!fs.existsSync(accumFile), 'Minimal profile must not accumulate edit paths'); + } finally { + fs.rmSync(accumFile, { force: true }); + } +})) passed++; else failed++; + +if (test('installed Cursor dispatchers resolve shared hooks inside .cursor/scripts', () => { + const fixture = createInstalledCursorFixture(); + try { + const blocked = runHook( + fixture.beforeShell, + { command: 'git commit --no-verify -m test' }, + {}, + fixture.projectRoot + ); + assert.strictEqual(blocked.status, 2, blocked.stderr); + assert.match(blocked.stderr, /installed runtime/); + + const edited = runHook( + fixture.afterFileEdit, + { path: path.join(fixture.projectRoot, 'src', 'app.js') }, + { CURSOR_HOOK_LOG: fixture.logPath }, + fixture.projectRoot + ); + assert.strictEqual(edited.status, 0, edited.stderr); + assert.strictEqual(fs.readFileSync(fixture.logPath, 'utf8'), 'post-edit-accumulator\n'); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('installed Cursor hooks keep profile controls without the shared helper', () => { + const fixture = createInstalledCursorFixture({ includeHookFlags: false }); + try { + const disabled = runHook( + fixture.beforeShell, + { command: 'git commit --no-verify -m test' }, + { ECC_HOOKS_ENABLED: 'false' }, + fixture.projectRoot + ); + assert.strictEqual(disabled.status, 0, disabled.stderr); + + const enabled = runHook( + fixture.beforeShell, + { command: 'git commit --no-verify -m test' }, + { ECC_HOOKS_ENABLED: 'true' }, + fixture.projectRoot + ); + assert.strictEqual(enabled.status, 2, enabled.stderr); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('installed Stop dispatcher resolves hooks inside .cursor/scripts', () => { + const fixture = createInstalledCursorFixture(); + try { + const result = runHook( + fixture.stop, + { conversation_id: 'installed-stop' }, + { + CURSOR_HOOK_LOG: fixture.logPath, + ECC_DISABLED_HOOKS: [ + 'stop:check-console-log', + 'stop:session-end', + 'stop:evaluate-session', + 'stop:cost-tracker', + ].join(','), + }, + fixture.projectRoot + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual( + fs.readFileSync(fixture.logPath, 'utf8'), + 'stop-format-typecheck:210000\n' + ); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('installed sessionStart dispatcher combines env and shared context', () => { + const fixture = createInstalledCursorFixture(); + try { + const result = runHook( + fixture.sessionStart, + { conversation_id: 'installed-session' }, + {}, + fixture.projectRoot + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' }, + additional_context: 'cursor env\n\nshared context', + }); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('installed sessionStart keeps environment fallback for malformed input', () => { + const fixture = createInstalledCursorFixture(); + try { + for (const raw of ['{not-json', 'null']) { + const result = runHook(fixture.sessionStart, raw, {}, fixture.projectRoot); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(JSON.parse(result.stdout).env.ECC_AGENT_DATA_HOME, '/tmp/cursor-ecc'); + } + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +})) passed++; else failed++; + +if (test('sessionStart dispatcher honors the master hook switch', () => { + const result = runHook( + path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'), + { conversation_id: 'disabled-session' }, + { ECC_HOOKS_ENABLED: 'false' } + ); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), {}); +})) passed++; else failed++; + +if (test('oversized beforeShellExecution input fails closed', () => { + const oversizedPayloads = [ + JSON.stringify({ + command: 'git commit --no-verify', + padding: 'x'.repeat(1024 * 1024), + }), + JSON.stringify({ + command: 'git commit --no-verify', + padding: '界'.repeat(400000), + }), + ]; + for (const oversized of oversizedPayloads) { + assert.ok(Buffer.byteLength(oversized, 'utf8') > 1024 * 1024); + const result = runHook(BEFORE_SHELL, oversized); + assert.strictEqual(result.status, 2, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /stdin exceeded.*blocking/i); + } +})) passed++; else failed++; + +if (test('oversized beforeShellExecution input honors explicit guard disablement', () => { + const oversized = JSON.stringify({ + command: 'git commit --no-verify', + padding: 'x'.repeat(1024 * 1024), + }); + for (const env of [ + { ECC_HOOKS_ENABLED: 'false' }, + { + ECC_DISABLED_HOOKS: [ + 'pre:bash:block-no-verify', + 'pre:bash:dev-server-block', + ].join(','), + }, + ]) { + const result = runHook(BEFORE_SHELL, oversized, env); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /stdin exceeded.*suppressing/i); + } +})) passed++; else failed++; + +if (test('oversized beforeTabFileRead input fails closed', () => { + const oversized = JSON.stringify({ + file_path: '.env', + padding: '界'.repeat(400000), + }); + const result = runHook(BEFORE_TAB_READ, oversized); + assert.strictEqual(result.status, 2, result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /stdin exceeded.*blocking/i); +})) passed++; else failed++; + +if (test('readStdin exposes stream failures to blocking dispatchers', () => { + const probe = [ + "const { EventEmitter } = require('events');", + `const { readStdin } = require(${JSON.stringify(path.join(REPO_ROOT, '.cursor', 'hooks', 'adapter.js'))});`, + "const stream = new EventEmitter();", + "stream.setEncoding = () => {};", + "readStdin({ includeMetadata: true, stream }).then(result => process.stdout.write(JSON.stringify(result)));", + "setImmediate(() => stream.emit('error', new Error('synthetic read failure')));", + ].join('\n'); + const result = spawnSync(process.execPath, ['-e', probe], { + encoding: 'utf8', + timeout: 5000, + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), { + raw: '', + truncated: false, + readError: true, + }); +})) passed++; else failed++; + +console.log('-'.repeat(55)); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/stop-format-typecheck.test.js b/tests/hooks/stop-format-typecheck.test.js index 564bae693..2b2504ecf 100644 --- a/tests/hooks/stop-format-typecheck.test.js +++ b/tests/hooks/stop-format-typecheck.test.js @@ -13,7 +13,12 @@ const os = require('os'); const path = require('path'); const accumulator = require('../../scripts/hooks/post-edit-accumulator'); -const { parseAccumulator, isPluginClonePath } = require('../../scripts/hooks/stop-format-typecheck'); +const { + getPerBatchBudgetMs, + getTotalBudgetMs, + isPluginClonePath, + parseAccumulator, +} = require('../../scripts/hooks/stop-format-typecheck'); function test(name, fn) { try { @@ -203,6 +208,27 @@ if (test('parseAccumulator ignores blank lines and trims whitespace', () => { assert.deepStrictEqual(result, ['/tmp/a.ts', '/tmp/b.ts']); })) passed++; else failed++; +if (test('getTotalBudgetMs honors a bounded dispatcher budget', () => { + assert.strictEqual( + getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000' }), + 210000 + ); + assert.strictEqual( + getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '999999' }), + 270000 + ); + assert.strictEqual( + getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: 'invalid' }), + 270000 + ); +})) passed++; else failed++; + +if (test('getPerBatchBudgetMs always returns a positive child timeout', () => { + assert.strictEqual(getPerBatchBudgetMs(210000, 2), 105000); + assert.strictEqual(getPerBatchBudgetMs(210000, 210001), 1); + assert.strictEqual(getPerBatchBudgetMs(210000, 0), 60000); +})) passed++; else failed++; + if (test('stop hook clears accumulator after processing duplicates', () => { cleanAccumFile(); fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8'); diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index fd195e127..5aff37acb 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -260,7 +260,7 @@ function runTests() { && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') && operation.strategy === 'preserve-relative-path' )), - 'Should preserve non-rule Cursor platform files' + 'Developer Cursor installs should register hooks through hooks-runtime' ); assert.ok( plan.operations.some(operation => ( diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 295898aae..fc1435bca 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -231,9 +231,11 @@ function runTests() { normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-coding-style.md' )); - assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files'); - assert.strictEqual(hooksJson.strategy, 'preserve-relative-path'); - assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json')); + assert.strictEqual( + hooksJson, + undefined, + 'Platform configs should not register hooks without hooks-runtime' + ); assert.ok(mcpJson, 'Should materialize a Cursor MCP config from the shared root MCP config'); assert.strictEqual(mcpJson.kind, 'merge-json'); assert.strictEqual(mcpJson.strategy, 'merge-json'); @@ -395,11 +397,10 @@ function runTests() { 'Should not preserve .md Cursor platform rule files' ); assert.ok( - plan.operations.some(operation => ( - normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' - && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') )), - 'Should preserve non-rule Cursor platform config files' + 'Should not register Cursor hooks without hooks-runtime' ); assert.ok( plan.operations.some(operation => ( @@ -479,6 +480,14 @@ function runTests() { '.cursor/hooks', 'Should prefer native Cursor hooks over generic hooks-runtime hooks' ); + const hooksConfig = plan.operations.find(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + )); + assert.strictEqual( + normalizedRelativePath(hooksConfig.sourceRelativePath), + '.cursor/hooks.json', + 'Should register the native Cursor hook config through hooks-runtime' + ); })) passed++; else failed++; if (test('plans native Antigravity 2.0 rules, workflows, skills, and agents', () => { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index a15aad786..e11c81773 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -255,6 +255,38 @@ function runTests() { const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json')); assert.strictEqual(hooksConfig.version, 1); assert.ok(hooksConfig.hooks.sessionStart, 'Should keep Cursor sessionStart hooks'); + assert.strictEqual( + hooksConfig.hooks.sessionStart[0].command, + 'node .cursor/hooks/session-start.js', + 'Cursor sessionStart should use the event-level dispatcher' + ); + for (const [eventName, entries] of Object.entries(hooksConfig.hooks)) { + assert.strictEqual( + entries.length, + 1, + `Installed Cursor event ${eventName} should use one dispatcher` + ); + } + + const installedShellDispatcher = path.join( + projectDir, + '.cursor', + 'hooks', + 'before-shell-execution.js' + ); + const blocked = spawnSync(process.execPath, [installedShellDispatcher], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, + input: JSON.stringify({ command: 'git commit --no-verify -m test' }), + encoding: 'utf8', + timeout: DEFAULT_INSTALL_APPLY_TIMEOUT_MS, + }); + assert.strictEqual( + blocked.status, + 2, + `Installed Cursor dispatcher should preserve blocking exit codes: ${blocked.stderr}` + ); + assert.match(blocked.stderr, /BLOCKED/); assert.ok(mcpConfig.mcpServers['chrome-devtools'], 'Should install shared MCP servers into Cursor'); const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json'); @@ -931,12 +963,12 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--target', 'cursor', '--modules', 'platform-configs', '--enable-hooks'], { + const result = run(['--target', 'cursor', '--modules', 'platform-configs'], { cwd: projectDir, homeDir, }); assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc'))); assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); @@ -947,6 +979,7 @@ function runTests() { assert.deepStrictEqual(state.request.excludeComponents, []); assert.strictEqual(state.request.legacyMode, false); assert.ok(state.resolution.selectedModules.includes('platform-configs')); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks'))); assert.ok( !state.operations.some(operation => operation.destinationPath.endsWith('ecc-install-state.json')), 'Manifest copy operations should not include generated install-state files'