mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-01 14:43:28 +08:00
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:
78
scripts/hooks/post-edit-accumulator.js
Normal file
78
scripts/hooks/post-edit-accumulator.js
Normal 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 };
|
||||
209
scripts/hooks/stop-format-typecheck.js
Normal file
209
scripts/hooks/stop-format-typecheck.js
Normal 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 };
|
||||
Reference in New Issue
Block a user