fix(hooks): skip stdin listeners on require and match exact tsc paths in post-edit-typecheck

config-protection.js and post-edit-typecheck.js attached process.stdin
listeners at module scope. tests/hooks/hook-input.test.js requires
config-protection.js, so that test never exits while stdin stays open.
Attach the listeners only when the script runs directly
(require.main === module), as stop-format-typecheck.js and quality-gate.js
already do.

post-edit-typecheck.js picked tsc diagnostics with line.includes(candidate),
so editing a root index.ts reported the errors of src/index.ts, and a
Windows relative path (src\index.ts) never matched tsc's forward-slash
output. Compare the file at the start of each diagnostic instead, the same
check diagnosticMatchesFile() does in stop-format-typecheck.js.

Refs #3821
This commit is contained in:
Quinn O'Donnell committed 2026-10-30 12:29:32 -04:00
1 parent b38be6a7f6
commit 00a8fe96f8
4 files changed
+268 -100

No files matched your search

+26 -24
View File
@@ -232,31 +232,33 @@ function checkTarget(filePath) {
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
if (require.main === module) {
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
});
if (result.exitCode === 2) {
process.exit(2);
}
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
process.stdout.write(raw);
});
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);
});
}
+91 -76
View File
@@ -14,92 +14,107 @@ const fs = require("fs");
const path = require("path");
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
let data = "";
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, "utf8");
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = "";
oversized = true;
return;
// tsc --pretty false starts each diagnostic with the file it is about:
// `src/a.ts(1,14): error TS2322: ...`. Compare that path instead of looking
// for the edited path anywhere in the line, which let a root `index.ts` claim
// the errors of `src/index.ts`. Same check as diagnosticMatchesFile() in
// stop-format-typecheck.js.
function diagnosticMatchesFile(line, paths) {
const location = /^(.+?)\(\d+,\d+\): /.exec(line);
if (!location) return false;
const file = location[1].replace(/\\/g, "/");
for (const candidate of paths) {
if (file === candidate.replace(/\\/g, "/")) return true;
}
data += chunk;
});
return false;
}
process.stdin.on("end", () => {
if (oversized) {
process.exit(0);
return;
}
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (require.main === module) {
let data = "";
let stdinBytes = 0;
let oversized = false;
process.stdin.setEncoding("utf8");
if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
process.stdout.write(data, () => process.exit(0));
return;
}
// Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root;
let depth = 0;
process.stdin.on("data", (chunk) => {
if (oversized) return;
stdinBytes += Buffer.byteLength(chunk, "utf8");
if (stdinBytes > MAX_DIRECT_STDIN_BYTES) {
data = "";
oversized = true;
return;
}
data += chunk;
});
while (dir !== root && depth < 20) {
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
break;
process.stdin.on("end", () => {
if (oversized) {
process.exit(0);
return;
}
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
process.stdout.write(data, () => process.exit(0));
return;
}
dir = path.dirname(dir);
depth++;
}
// Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root;
let depth = 0;
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
try {
// Use npx.cmd on Windows to avoid shell: true which enables command injection
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
execFileSync(npxBin, ["tsc", "--noEmit", "--pretty", "false"], {
cwd: dir,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 30000,
});
} catch (err) {
// tsc exits non-zero when there are errors — filter to edited file
const output = (err.stdout || "") + (err.stderr || "");
// Compute paths that uniquely identify the edited file.
// tsc output uses paths relative to its cwd (the tsconfig dir),
// so check for the relative path, absolute path, and original path.
// Avoid bare basename matching — it causes false positives when
// multiple files share the same name (e.g., src/utils.ts vs tests/utils.ts).
const relPath = path.relative(dir, resolvedPath);
const candidates = new Set([filePath, resolvedPath, relPath]);
const relevantLines = output
.split("\n")
.filter((line) => {
for (const candidate of candidates) {
if (line.includes(candidate)) return true;
}
return false;
})
.slice(0, 10);
while (dir !== root && depth < 20) {
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
break;
}
dir = path.dirname(dir);
depth++;
}
if (relevantLines.length > 0) {
console.error(
"[Hook] TypeScript errors in " + path.basename(filePath) + ":",
);
relevantLines.forEach((line) => console.error(line));
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
try {
// Use npx.cmd on Windows to avoid shell: true which enables command injection
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
execFileSync(npxBin, ["tsc", "--noEmit", "--pretty", "false"], {
cwd: dir,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 30000,
});
} catch (err) {
// tsc exits non-zero when there are errors — filter to edited file
const output = (err.stdout || "") + (err.stderr || "");
// Compute paths that uniquely identify the edited file.
// tsc output uses paths relative to its cwd (the tsconfig dir),
// so check for the relative path, absolute path, and original path.
// Avoid bare basename matching — it causes false positives when
// multiple files share the same name (e.g., src/utils.ts vs tests/utils.ts).
const relPath = path.relative(dir, resolvedPath);
const candidates = new Set([filePath, resolvedPath, relPath]);
const relevantLines = output
.split("\n")
.filter((line) => diagnosticMatchesFile(line, candidates))
.slice(0, 10);
if (relevantLines.length > 0) {
console.error(
"[Hook] TypeScript errors in " + path.basename(filePath) + ":",
);
relevantLines.forEach((line) => console.error(line));
}
}
}
}
} catch {
// Invalid input — pass through
}
} catch {
// Invalid input — pass through
}
process.stdout.write(data, () => process.exit(0));
});
process.stdout.write(data, () => process.exit(0));
});
}
module.exports = { diagnosticMatchesFile };