Files
everything-claude-code/scripts/hooks/post-edit-accumulator.js
Yuval Dinodia 95e606fb81 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
2026-03-31 14:12:12 -07:00

79 lines
2.3 KiB
JavaScript

#!/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 };