perf(hooks): batch format+typecheck at Stop instead of per Edit (#746)

* 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
This commit is contained in:
Yuval Dinodia
2026-03-31 17:12:12 -04:00
committed by GitHub
parent eacf3a9fb4
commit 95e606fb81
5 changed files with 551 additions and 16 deletions

View File

@@ -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);

View File

@@ -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": [

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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);