From 95e606fb81efcf5fa67344cb347066d7653197df Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:12:12 -0400 Subject: [PATCH] perf(hooks): batch format+typecheck at Stop instead of per Edit (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(hooks): batch format+typecheck at Stop instead of per Edit Fixes #735. The per-edit post:edit:format and post:edit:typecheck hooks ran synchronously after every Edit call, adding 15-30s of latency per file — up to 7.5 minutes for a 10-file refactor. New approach: - post-edit-accumulator.js (PostToolUse/Edit): lightweight hook that records each edited JS/TS path to a session-scoped temp file in os.tmpdir(). No formatters, no tsc — exits in microseconds. - stop-format-typecheck.js (Stop): reads the accumulator once per response, groups files by project root and runs the formatter in one batched invocation per root, then groups .ts/.tsx files by tsconfig dir and runs tsc once per tsconfig. Clears the accumulator immediately on read so repeated Stop calls don't double-process. For a 10-file refactor: was 10 × (15s + 30s) = 7.5 min overhead, now 1 × (batch format + batch tsc) = ~5-30s total. * fix(hooks): address race condition, spawn timeout, and Windows path guard Three issues raised in code review: 1. Race condition: switched accumulator from non-atomic JSON read-modify-write to appendFileSync (one path per line). Concurrent Edit hook processes each append independently without clobbering each other. Deduplication moved to the Stop hook at read time. 2. Effective timeout: added run() export to stop-format-typecheck.js so run-with-flags.js uses the direct require() path instead of falling through to spawnSync (which has a hardcoded 30s cap). The 120s timeout in hooks.json now governs the full batch as intended. 3. Windows path guard: added spaces and parentheses to UNSAFE_PATH_CHARS so paths like "C:\Users\John Doe\project\file.ts" are caught before being passed to cmd.exe with shell: true. * fix(hooks): fix session fallback, stale comment, trim verbose comments - Replace 'default' session ID fallback with a cwd-based sha1 hash so concurrent sessions in different projects don't share the same accumulator file when CLAUDE_SESSION_ID is unset - Remove stale "JSON file" reference in accumulator header (format is now newline-delimited plain text) - Remove redundant/verbose inline comments throughout both files * fix(hooks): sanitize session ID, fix Windows tsc, proportional timeouts - Sanitize CLAUDE_SESSION_ID with /[^a-zA-Z0-9_-]/g before embedding in the temp filename so crafted separators or '..' sequences cannot escape os.tmpdir() (cubic P1) - Fix typecheckBatch on Windows: npx.cmd requires shell:true like formatBatch already does; use spawnSync and extract stdout/stderr from the result object (coderabbit P1) - Proportional per-batch timeouts: divide 270s budget across all format and typecheck batches so sequential runs in monorepos stay within the Stop hook wall-clock limit (greptile P2) - Raise Stop hook timeout from 120s to 300s to give large monorepos adequate headroom (cubic P2) * fix(hooks): extend accumulator to Write|MultiEdit, fix tests - Extend matcher from Edit to Edit|Write|MultiEdit so files created with Write and all files in a MultiEdit batch are included in the Stop-time format+typecheck pass (cubic P1) - Handle tool_input.edits[] array in accumulator for MultiEdit support - Rename misleading 'concurrent writes' test to clarify it tests append preservation, not true concurrency (cubic P2) - Add Stop hook dedup test: writes duplicate paths to accumulator and verifies the hook clears it cleanly (cubic P2) - Add Write and MultiEdit accumulation tests * fix(hooks): move timeout to command level, add dedup unit tests - Move timeout: 300 from the matcher object to the hook command object where it is actually enforced; the previous position was a no-op (cubic P2) - Extract parseAccumulator() and export it so tests can assert dedup behavior directly without relying only on side effects (cubic P2) - Add two unit tests for parseAccumulator: deduplication and blank-line handling; rename the integration test to match its scope * fix(hooks): replace removed format/typecheck hooks with accumulator in cursor adapter --- .cursor/hooks/after-file-edit.js | 5 +- hooks/hooks.json | 27 +-- scripts/hooks/post-edit-accumulator.js | 78 +++++++ scripts/hooks/stop-format-typecheck.js | 209 ++++++++++++++++++ tests/hooks/stop-format-typecheck.test.js | 248 ++++++++++++++++++++++ 5 files changed, 551 insertions(+), 16 deletions(-) create mode 100644 scripts/hooks/post-edit-accumulator.js create mode 100644 scripts/hooks/stop-format-typecheck.js create mode 100644 tests/hooks/stop-format-typecheck.test.js diff --git a/.cursor/hooks/after-file-edit.js b/.cursor/hooks/after-file-edit.js index f4c39e49..58fd0fb9 100644 --- a/.cursor/hooks/after-file-edit.js +++ b/.cursor/hooks/after-file-edit.js @@ -8,9 +8,8 @@ readStdin().then(raw => { }); const claudeStr = JSON.stringify(claudeInput); - // Run format, typecheck, and console.log warning sequentially - runExistingHook('post-edit-format.js', claudeStr); - runExistingHook('post-edit-typecheck.js', claudeStr); + // Accumulate edited paths for batch format+typecheck at stop time + runExistingHook('post-edit-accumulator.js', claudeStr); runExistingHook('post-edit-console-warn.js', claudeStr); } catch {} process.stdout.write(raw); diff --git a/hooks/hooks.json b/hooks/hooks.json index e54c6a4e..5cccc6e8 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -208,24 +208,14 @@ "description": "Run quality gate checks after file edits" }, { - "matcher": "Edit", + "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:format\" \"scripts/hooks/post-edit-format.js\" \"strict\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:accumulate\" \"scripts/hooks/post-edit-accumulator.js\" \"standard,strict\"" } ], - "description": "Auto-format JS/TS files after edits (auto-detects Biome or Prettier)" - }, - { - "matcher": "Edit", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:typecheck\" \"scripts/hooks/post-edit-typecheck.js\" \"strict\"" - } - ], - "description": "TypeScript check after editing .ts/.tsx files" + "description": "Record edited JS/TS file paths for batch format+typecheck at Stop time" }, { "matcher": "Edit", @@ -274,6 +264,17 @@ } ], "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"stop:format-typecheck\" \"scripts/hooks/stop-format-typecheck.js\" \"standard,strict\"", + "timeout": 300 + } + ], + "description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit" + }, { "matcher": "*", "hooks": [ diff --git a/scripts/hooks/post-edit-accumulator.js b/scripts/hooks/post-edit-accumulator.js new file mode 100644 index 00000000..c626b2dd --- /dev/null +++ b/scripts/hooks/post-edit-accumulator.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * PostToolUse Hook: Accumulate edited JS/TS file paths for batch processing + * + * Cross-platform (Windows, macOS, Linux) + * + * Records each edited JS/TS path to a session-scoped temp file (one path per + * line). stop-format-typecheck.js reads this list at Stop time and runs format + * + typecheck once across all edited files, eliminating per-edit latency. + * + * appendFileSync is used so concurrent hook processes write atomically + * without overwriting each other. Deduplication is deferred to the Stop hook. + */ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const MAX_STDIN = 1024 * 1024; + +function getAccumFile() { + const raw = + process.env.CLAUDE_SESSION_ID || + crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12); + // Strip path separators and traversal sequences so the value is safe to embed + // directly in a filename regardless of what CLAUDE_SESSION_ID contains. + const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); + return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`); +} + +/** + * @param {string} rawInput - Raw JSON string from stdin + * @returns {string} The original input (pass-through) + */ +const JS_TS_EXT = /\.(ts|tsx|js|jsx)$/; + +function appendPath(filePath) { + if (filePath && JS_TS_EXT.test(filePath)) { + fs.appendFileSync(getAccumFile(), filePath + '\n', 'utf8'); + } +} + +/** + * @param {string} rawInput - Raw JSON string from stdin + * @returns {string} The original input (pass-through) + */ +function run(rawInput) { + try { + const input = JSON.parse(rawInput); + // Edit / Write: single file_path + appendPath(input.tool_input?.file_path); + // MultiEdit: array of edits, each with its own file_path + const edits = input.tool_input?.edits; + if (Array.isArray(edits)) { + for (const edit of edits) appendPath(edit?.file_path); + } + } catch { + // Invalid input — pass through + } + return rawInput; +} + +if (require.main === module) { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length); + }); + process.stdin.on('end', () => { + process.stdout.write(run(data)); + process.exit(0); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/stop-format-typecheck.js b/scripts/hooks/stop-format-typecheck.js new file mode 100644 index 00000000..50c5d2c6 --- /dev/null +++ b/scripts/hooks/stop-format-typecheck.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Stop Hook: Batch format and typecheck all JS/TS files edited this response + * + * Cross-platform (Windows, macOS, Linux) + * + * Reads the accumulator written by post-edit-accumulator.js and processes all + * edited files in one pass: groups files by project root for a single formatter + * invocation per root, and groups .ts/.tsx files by tsconfig dir for a single + * tsc --noEmit per tsconfig. The accumulator is cleared on read so repeated + * Stop calls do not double-process files. + * + * Per-batch timeout is proportional to the number of batches so the total + * never exceeds the Stop hook budget (90 s reserved for overhead). + */ + +'use strict'; + +const crypto = require('crypto'); +const { execFileSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter'); + +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; + +// Characters cmd.exe treats as separators/operators when shell: true is used. +// Includes spaces and parentheses to guard paths like "C:\Users\John Doe\...". +const UNSAFE_PATH_CHARS = /[&|<>^%!\s()]/; + +/** Parse the accumulator text into a deduplicated array of file paths. */ +function parseAccumulator(raw) { + return [...new Set(raw.split('\n').map(l => l.trim()).filter(Boolean))]; +} + +function getAccumFile() { + const raw = + process.env.CLAUDE_SESSION_ID || + crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12); + const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); + return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`); +} + +function formatBatch(projectRoot, files, timeoutMs) { + const formatter = detectFormatter(projectRoot); + if (!formatter) return; + + const resolved = resolveFormatterBin(projectRoot, formatter); + if (!resolved) return; + + const existingFiles = files.filter(f => fs.existsSync(f)); + if (existingFiles.length === 0) return; + + const fileArgs = + formatter === 'biome' + ? [...resolved.prefix, 'check', '--write', ...existingFiles] + : [...resolved.prefix, '--write', ...existingFiles]; + + try { + if (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) { + if (existingFiles.some(f => UNSAFE_PATH_CHARS.test(f))) { + process.stderr.write('[Hook] stop-format-typecheck: skipping batch — unsafe path chars\n'); + return; + } + const result = spawnSync(resolved.bin, fileArgs, { cwd: projectRoot, shell: true, stdio: 'pipe', timeout: timeoutMs }); + if (result.error) throw result.error; + } else { + execFileSync(resolved.bin, fileArgs, { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs }); + } + } catch { + // Formatter not installed or failed — non-blocking + } +} + +function findTsConfigDir(filePath) { + let dir = path.dirname(filePath); + const fsRoot = path.parse(dir).root; + let depth = 0; + while (dir !== fsRoot && depth < 20) { + if (fs.existsSync(path.join(dir, 'tsconfig.json'))) return dir; + dir = path.dirname(dir); + depth++; + } + return null; +} + +function typecheckBatch(tsConfigDir, editedFiles, timeoutMs) { + const isWin = process.platform === 'win32'; + const npxBin = isWin ? 'npx.cmd' : 'npx'; + const args = ['tsc', '--noEmit', '--pretty', 'false']; + const opts = { cwd: tsConfigDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs }; + + let stdout = ''; + let stderr = ''; + let failed = false; + + try { + if (isWin) { + // .cmd files require shell: true on Windows + const result = spawnSync(npxBin, args, { ...opts, shell: true }); + if (result.error) return; // timed out or not found — non-blocking + if (result.status !== 0) { + stdout = result.stdout || ''; + stderr = result.stderr || ''; + failed = true; + } + } else { + execFileSync(npxBin, args, opts); + } + } catch (err) { + stdout = err.stdout || ''; + stderr = err.stderr || ''; + failed = true; + } + + if (!failed) return; + + const lines = (stdout + stderr).split('\n'); + for (const filePath of editedFiles) { + const relPath = path.relative(tsConfigDir, filePath); + const candidates = new Set([filePath, relPath]); + const relevantLines = lines + .filter(line => { for (const c of candidates) { if (line.includes(c)) return true; } return false; }) + .slice(0, 10); + if (relevantLines.length > 0) { + process.stderr.write(`[Hook] TypeScript errors in ${path.basename(filePath)}:\n`); + relevantLines.forEach(line => process.stderr.write(line + '\n')); + } + } +} + +function main() { + const accumFile = getAccumFile(); + + let raw; + try { + raw = fs.readFileSync(accumFile, 'utf8'); + } catch { + return; // No accumulator — nothing edited this response + } + + try { fs.unlinkSync(accumFile); } catch { /* best-effort */ } + + const files = parseAccumulator(raw); + if (files.length === 0) return; + + const byProjectRoot = new Map(); + for (const filePath of files) { + if (!/\.(ts|tsx|js|jsx)$/.test(filePath)) continue; + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) continue; + const root = findProjectRoot(path.dirname(resolved)); + if (!byProjectRoot.has(root)) byProjectRoot.set(root, []); + byProjectRoot.get(root).push(resolved); + } + + const byTsConfigDir = new Map(); + for (const filePath of files) { + if (!/\.(ts|tsx)$/.test(filePath)) continue; + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) continue; + const tsDir = findTsConfigDir(resolved); + if (!tsDir) continue; + if (!byTsConfigDir.has(tsDir)) byTsConfigDir.set(tsDir, []); + byTsConfigDir.get(tsDir).push(resolved); + } + + // 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; + + for (const [root, batch] of byProjectRoot) formatBatch(root, batch, perBatchMs); + for (const [tsDir, batch] of byTsConfigDir) typecheckBatch(tsDir, batch, perBatchMs); +} + +/** + * Exported so run-with-flags.js uses require() instead of spawnSync, + * letting the 300s hooks.json timeout govern the full batch. + * + * @param {string} rawInput - Raw JSON string from stdin (Stop event payload) + * @returns {string} The original input (pass-through) + */ +function run(rawInput) { + try { + main(); + } catch (err) { + process.stderr.write(`[Hook] stop-format-typecheck error: ${err.message}\n`); + } + return rawInput; +} + +if (require.main === module) { + let stdinData = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (stdinData.length < MAX_STDIN) stdinData += chunk.substring(0, MAX_STDIN - stdinData.length); + }); + process.stdin.on('end', () => { + process.stdout.write(run(stdinData)); + process.exit(0); + }); +} + +module.exports = { run, parseAccumulator }; diff --git a/tests/hooks/stop-format-typecheck.test.js b/tests/hooks/stop-format-typecheck.test.js new file mode 100644 index 00000000..b509e949 --- /dev/null +++ b/tests/hooks/stop-format-typecheck.test.js @@ -0,0 +1,248 @@ +/** + * Tests for scripts/hooks/post-edit-accumulator.js and + * scripts/hooks/stop-format-typecheck.js + * + * Run with: node tests/hooks/stop-format-typecheck.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const accumulator = require('../../scripts/hooks/post-edit-accumulator'); +const { parseAccumulator } = require('../../scripts/hooks/stop-format-typecheck'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +let passed = 0; +let failed = 0; + +// Use a unique session ID for tests so we don't pollute real sessions +const TEST_SESSION_ID = `test-${Date.now()}`; +const origSessionId = process.env.CLAUDE_SESSION_ID; +process.env.CLAUDE_SESSION_ID = TEST_SESSION_ID; + +function getAccumFile() { + return path.join(os.tmpdir(), `ecc-edited-${TEST_SESSION_ID}.txt`); +} + +function cleanAccumFile() { + try { fs.unlinkSync(getAccumFile()); } catch { /* doesn't exist */ } +} + +// ── post-edit-accumulator.js ───────────────────────────────────── + +console.log('\npost-edit-accumulator: pass-through behavior'); +console.log('=============================================\n'); + +if (test('returns original input unchanged', () => { + cleanAccumFile(); + const input = JSON.stringify({ tool_input: { file_path: '/tmp/x.ts' } }); + const result = accumulator.run(input); + assert.strictEqual(result, input); + cleanAccumFile(); +})) passed++; else failed++; + +if (test('returns original input for invalid JSON', () => { + cleanAccumFile(); + const input = 'not json'; + const result = accumulator.run(input); + assert.strictEqual(result, input); +})) passed++; else failed++; + +if (test('returns original input when no file_path', () => { + cleanAccumFile(); + const input = JSON.stringify({ tool_input: { command: 'ls' } }); + const result = accumulator.run(input); + assert.strictEqual(result, input); + cleanAccumFile(); +})) passed++; else failed++; + +console.log('\npost-edit-accumulator: file accumulation'); +console.log('=========================================\n'); + +if (test('creates accumulator file for a .ts file', () => { + cleanAccumFile(); + const input = JSON.stringify({ tool_input: { file_path: '/tmp/foo.ts' } }); + accumulator.run(input); + const accumFile = getAccumFile(); + assert.ok(fs.existsSync(accumFile), 'accumulator file should exist'); + const lines = fs.readFileSync(accumFile, 'utf8').split('\n').filter(Boolean); + assert.ok(lines.includes('/tmp/foo.ts')); + cleanAccumFile(); +})) passed++; else failed++; + +if (test('accumulates multiple files across calls', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/a.ts' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/b.tsx' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/c.js' } })); + const lines = fs.readFileSync(getAccumFile(), 'utf8').split('\n').filter(Boolean); + assert.deepStrictEqual(lines, ['/tmp/a.ts', '/tmp/b.tsx', '/tmp/c.js']); + cleanAccumFile(); +})) passed++; else failed++; + +if (test('all appended paths are preserved including duplicates (dedup is Stop hook responsibility)', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/a.ts' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/b.ts' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/a.ts' } })); // duplicate + const lines = fs.readFileSync(getAccumFile(), 'utf8').split('\n').filter(Boolean); + assert.strictEqual(lines.length, 3); // all three appends land + assert.strictEqual(new Set(lines).size, 2); // two unique paths + cleanAccumFile(); +})) passed++; else failed++; + +if (test('accumulates Write tool file_path', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/new-file.ts' } })); + const lines = fs.readFileSync(getAccumFile(), 'utf8').split('\n').filter(Boolean); + assert.ok(lines.includes('/tmp/new-file.ts')); + cleanAccumFile(); +})) passed++; else failed++; + +if (test('accumulates MultiEdit edits array paths', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ + tool_input: { + edits: [ + { file_path: '/tmp/multi-a.ts', old_string: 'a', new_string: 'b' }, + { file_path: '/tmp/multi-b.tsx', old_string: 'c', new_string: 'd' }, + { file_path: '/tmp/skip.md', old_string: 'e', new_string: 'f' } + ] + } + })); + const lines = fs.readFileSync(getAccumFile(), 'utf8').split('\n').filter(Boolean); + assert.ok(lines.includes('/tmp/multi-a.ts')); + assert.ok(lines.includes('/tmp/multi-b.tsx')); + assert.ok(!lines.includes('/tmp/skip.md'), 'non-JS/TS should be excluded'); + cleanAccumFile(); +})) passed++; else failed++; + +if (test('does not create accumulator file for non-JS/TS files', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/README.md' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/styles.css' } })); + assert.ok(!fs.existsSync(getAccumFile()), 'no accumulator for non-JS/TS files'); +})) passed++; else failed++; + +if (test('handles .tsx and .jsx extensions', () => { + cleanAccumFile(); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/comp.tsx' } })); + accumulator.run(JSON.stringify({ tool_input: { file_path: '/tmp/comp.jsx' } })); + const lines = fs.readFileSync(getAccumFile(), 'utf8').split('\n').filter(Boolean); + assert.ok(lines.includes('/tmp/comp.tsx')); + assert.ok(lines.includes('/tmp/comp.jsx')); + cleanAccumFile(); +})) passed++; else failed++; + +// ── stop-format-typecheck: accumulator teardown ────────────────── + +console.log('\nstop-format-typecheck: accumulator cleanup'); +console.log('==========================================\n'); + +if (test('stop hook removes accumulator file after reading it', () => { + cleanAccumFile(); + // Write a fake accumulator with a non-existent file so no real formatter runs + fs.writeFileSync(getAccumFile(), '/nonexistent/file.ts\n', 'utf8'); + assert.ok(fs.existsSync(getAccumFile()), 'accumulator should exist before stop hook'); + + // Require the stop hook and invoke main() directly via its stdin entry. + // We simulate the stdin+stdout flow by spawning node and feeding empty stdin. + const { execFileSync } = require('child_process'); + const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js'); + try { + execFileSync('node', [stopScript], { + input: '{}', + env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); + } catch { + // tsc/formatter may fail for the nonexistent file — that's OK + } + + assert.ok(!fs.existsSync(getAccumFile()), 'accumulator file should be deleted by stop hook'); +})) passed++; else failed++; + +if (test('stop hook is a no-op when no accumulator exists', () => { + cleanAccumFile(); + const { execFileSync } = require('child_process'); + const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js'); + // Should exit cleanly with no errors + execFileSync('node', [stopScript], { + input: '{}', + env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); +})) passed++; else failed++; + +if (test('parseAccumulator deduplicates repeated paths', () => { + const raw = '/tmp/a.ts\n/tmp/b.ts\n/tmp/a.ts\n/tmp/a.ts\n/tmp/c.js\n'; + const result = parseAccumulator(raw); + assert.deepStrictEqual(result, ['/tmp/a.ts', '/tmp/b.ts', '/tmp/c.js']); +})) passed++; else failed++; + +if (test('parseAccumulator ignores blank lines and trims whitespace', () => { + const raw = ' /tmp/a.ts \n\n/tmp/b.ts\n\n'; + const result = parseAccumulator(raw); + assert.deepStrictEqual(result, ['/tmp/a.ts', '/tmp/b.ts']); +})) 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'); + const { execFileSync } = require('child_process'); + const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js'); + try { + execFileSync('node', [stopScript], { + input: '{}', + env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); + } catch { /* formatter/tsc may fail for nonexistent files */ } + assert.ok(!fs.existsSync(getAccumFile()), 'accumulator cleared after stop hook'); +})) passed++; else failed++; + +if (test('stop hook passes stdin through unchanged', () => { + cleanAccumFile(); + const { execFileSync } = require('child_process'); + const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js'); + const input = '{"stop_reason":"end_turn"}'; + const result = execFileSync('node', [stopScript], { + input, + env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 + }); + assert.strictEqual(result.toString(), input); +})) passed++; else failed++; + +// Restore env +if (origSessionId === undefined) { + delete process.env.CLAUDE_SESSION_ID; +} else { + process.env.CLAUDE_SESSION_ID = origSessionId; +} + +console.log(`\n=== Test Results ===`); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}`); + +process.exit(failed > 0 ? 1 : 0);