From 99aa85dde9f740a56e6cc37717bb0febaf6b402c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 21:15:25 +0200 Subject: [PATCH 01/11] fix(hooks): let the dev-server block see a tmux launch past tmux options The block allowed a dev server only when the segment began with `tmux new`, `new-session`, `new-window` or `split-window`. It therefore blocked dev servers that do start in a tmux pane, the way its own message asks: `tmux -L work new-session ...` and `tmux -f conf new-session ...` (tmux options before the command), `/opt/homebrew/bin/tmux new-session ...` and `env TERM=... tmux new-session ...`, the aliases `neww` and `splitw`, and `tmux send-keys -t dev 'npm run dev' Enter` or `respawn-pane` for a session that exists. Read the tmux command from the parsed segment instead, after the leading command word and tmux's own options, and allow the commands that run their shell command in a pane. `tmux run-shell` and `tmux -c` run it without one and stay blocked. --- scripts/hooks/pre-bash-dev-server-block.js | 52 ++++++++++++++++--- tests/hooks/pre-bash-dev-server-block.test.js | 28 ++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 06eb64020..5dcaddb56 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -104,7 +104,7 @@ function normalizeCommandWord(token) { return base.replace(/\.(cmd|exe|bat)$/i, ''); } -function getLeadingCommandWord(segment) { +function getLeadingCommand(segment) { let index = 0; let activeWrapper = null; let skipNextValue = false; @@ -145,7 +145,7 @@ function getLeadingCommandWord(segment) { continue; } - return normalizedToken; + return { word: normalizedToken, end: index }; } return null; @@ -160,7 +160,18 @@ process.stdin.on('data', chunk => { } }); -const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; +// tmux commands, and their aliases, that run their shell command in a tmux pane, +// where its output stays readable. run-shell and `tmux -c` run it without one. +const TMUX_PANE_COMMANDS = new Set([ + 'new-session', 'new', + 'new-window', 'neww', + 'split-window', 'splitw', + 'respawn-pane', 'respawnp', + 'respawn-window', 'respawnw', + 'send-keys', 'send' +]); +// Options that come before the tmux command and take a value. +const TMUX_OPTIONS_WITH_VALUE = new Set(['-f', '-L', '-S', '-T']); // Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -199,10 +210,39 @@ function collectCheckSegments(cmd) { return segments; } +/** + * Return true when the tmux invocation that starts at `index` runs its command + * in a pane. The tmux command follows tmux's own options (`-L work`, `-f conf`), + * and the executable may come through a path or an env prefix, so this reads + * the parsed tokens instead of the start of the segment. + */ +function isTmuxPaneLaunch(segment, index) { + while (index < segment.length) { + const parsed = readToken(segment, index); + if (!parsed) return false; + index = parsed.end; + + const token = parsed.token; + if (!token) continue; + if (token === '-c') return false; + if (TMUX_OPTIONS_WITH_VALUE.has(token)) { + const value = readToken(segment, index); + if (!value) return false; + index = value.end; + continue; + } + if (isOptionToken(token)) continue; + return TMUX_PANE_COMMANDS.has(token); + } + + return false; +} + function isBlockedDevSegment(segment) { - const commandWord = getLeadingCommandWord(segment); - if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false; - return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment); + const command = getLeadingCommand(segment); + if (!command || !DEV_COMMAND_WORDS.has(command.word)) return false; + if (!DEV_PATTERN.test(segment)) return false; + return !(command.word === 'tmux' && isTmuxPaneLaunch(segment, command.end)); } process.stdin.on('end', () => { diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 89524b1e0..695a16846 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -68,6 +68,13 @@ function runTests() { const result = runScript('npm run dev:ssr'); assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); }) ? passed++ : failed++); + + (test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => { + for (const command of ['tmux run-shell "npm run dev"', 'tmux -L work run-shell "npm run dev"', 'tmux -c "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); } else { console.log(' (skipping blocking tests on Windows)\n'); } @@ -79,6 +86,27 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); }) ? passed++ : failed++); + (test('allows a tmux launch after tmux options, a path, an env prefix or a command alias (exit code 0)', () => { + for (const command of [ + 'tmux -L work new-session -d -s dev "npm run dev"', + 'tmux -f ~/.tmux.dev.conf new-session -d "npm run dev"', + '/opt/homebrew/bin/tmux new-session -d -s dev "npm run dev"', + 'env TERM=xterm-256color tmux new-session -d "npm run dev"', + 'tmux neww "npm run dev"', + 'tmux splitw -h "pnpm dev"' + ]) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + + (test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => { + for (const command of ['tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + (test('allows npm install (exit code 0)', () => { const result = runScript('npm install'); assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); From 87945aac1fee7cfadad4f18b3d77b36ecbb607ea Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 21:59:23 +0200 Subject: [PATCH 02/11] fix(hooks): read tmux option clusters and command sequences in the dev-server block Two forms got the pane exemption although they run the dev server without a pane. `tmux -vL new-session run-shell ...`: in a cluster, the last option that takes a value (f, L, S, T) takes the next token, here the socket name `new-session`. A `c` anywhere in a cluster (`-c'npm run dev'`) runs a shell command without a pane. `tmux new-session -d \; run-shell "npm run dev"`: only the first command of a sequence was checked. The sequence is now split at `;`, and it is exempt only when every command in it that runs the dev server is a pane command. This also closes the same hole for the plain `tmux new-session ... \; run-shell`, which the old regex exempted. --- scripts/hooks/pre-bash-dev-server-block.js | 55 +++++++++++-------- tests/hooks/pre-bash-dev-server-block.test.js | 18 +++++- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 5dcaddb56..7d57a354b 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -170,8 +170,9 @@ const TMUX_PANE_COMMANDS = new Set([ 'respawn-window', 'respawnw', 'send-keys', 'send' ]); -// Options that come before the tmux command and take a value. -const TMUX_OPTIONS_WITH_VALUE = new Set(['-f', '-L', '-S', '-T']); +// tmux's own options that take a value (`-L work`, `-Lwork`, or the last of a +// cluster such as `-vL work`). +const TMUX_OPTION_VALUE_LETTERS = new Set(['f', 'L', 'S', 'T']); // Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -211,31 +212,39 @@ function collectCheckSegments(cmd) { } /** - * Return true when the tmux invocation that starts at `index` runs its command - * in a pane. The tmux command follows tmux's own options (`-L work`, `-f conf`), - * and the executable may come through a path or an env prefix, so this reads - * the parsed tokens instead of the start of the segment. + * Return true when the tmux invocation that starts at `index` runs the dev + * server in a pane. The tmux commands follow tmux's own options (`-L work`, + * `-f conf`), and the executable may come through a path or an env prefix, so + * this reads the parsed tokens instead of the start of the segment. A sequence + * (`new-session -d \; run-shell ...`) is exempt only when every command in it + * that runs the dev server is a pane command. */ function isTmuxPaneLaunch(segment, index) { - while (index < segment.length) { - const parsed = readToken(segment, index); - if (!parsed) return false; - index = parsed.end; - - const token = parsed.token; - if (!token) continue; - if (token === '-c') return false; - if (TMUX_OPTIONS_WITH_VALUE.has(token)) { - const value = readToken(segment, index); - if (!value) return false; - index = value.end; - continue; - } - if (isOptionToken(token)) continue; - return TMUX_PANE_COMMANDS.has(token); + const tokens = []; + for (let parsed = readToken(segment, index); parsed; parsed = readToken(segment, parsed.end)) { + if (parsed.token) tokens.push(parsed.token); } - return false; + let next = 0; + for (; next < tokens.length && isOptionToken(tokens[next]); next++) { + const letters = tokens[next].slice(1); + for (let i = 0; i < letters.length; i++) { + // -c runs its shell command without a pane. + if (letters[i] === 'c') return false; + if (TMUX_OPTION_VALUE_LETTERS.has(letters[i])) { + if (i === letters.length - 1) next++; + break; + } + } + } + + const commands = [[]]; + for (const token of tokens.slice(next)) { + if (token === ';') commands.push([]); + else commands[commands.length - 1].push(token); + } + const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' '))); + return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0])); } function isBlockedDevSegment(segment) { diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 695a16846..799af5034 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -70,7 +70,17 @@ function runTests() { }) ? passed++ : failed++); (test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => { - for (const command of ['tmux run-shell "npm run dev"', 'tmux -L work run-shell "npm run dev"', 'tmux -c "npm run dev"']) { + for (const command of [ + 'tmux run-shell "npm run dev"', + 'tmux -L work run-shell "npm run dev"', + 'tmux -c "npm run dev"', + "tmux -c'npm run dev'", + // In -vL the L takes `new-session` as its socket name, so run-shell is the command. + 'tmux -vL new-session run-shell "npm run dev"', + // A pane command earlier in a sequence does not cover a later run-shell. + 'tmux new-session -d \\; run-shell "npm run dev"', + 'tmux -L work new-session -d \\; run-shell "npm run dev"' + ]) { const result = runScript(command); assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); } @@ -101,7 +111,11 @@ function runTests() { }) ? passed++ : failed++); (test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => { - for (const command of ['tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"']) { + for (const command of [ + 'tmux send-keys -t dev "npm run dev" Enter', + 'tmux respawn-pane -k -t dev "npm run dev"', + 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter' + ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); } From eee00776be14d8e00705aa799bee3b4737192f7c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 22:32:33 +0200 Subject: [PATCH 03/11] fix(hooks): end a tmux command at a semicolon that ends an argument tmux also separates commands at a `;` that ends an argument, so `new-session -d 'npm run dev;' run-shell 'npm run dev'` runs the second dev server without a pane. Split there as well. An escaped `\;` at the end of an argument stays literal, as in tmux. --- scripts/hooks/pre-bash-dev-server-block.js | 8 ++++++-- tests/hooks/pre-bash-dev-server-block.test.js | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 7d57a354b..ec86f112d 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -238,10 +238,14 @@ function isTmuxPaneLaunch(segment, index) { } } + // tmux ends a command at a `;` argument and at a `;` that ends an argument + // (`'npm run dev;'`); an escaped `\;` at the end stays literal. const commands = [[]]; for (const token of tokens.slice(next)) { - if (token === ';') commands.push([]); - else commands[commands.length - 1].push(token); + const ends = token.endsWith(';') && !token.endsWith('\\;'); + const value = ends ? token.slice(0, -1) : token; + if (value) commands[commands.length - 1].push(value); + if (ends) commands.push([]); } const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' '))); return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0])); diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 799af5034..7427828f4 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -79,7 +79,9 @@ function runTests() { 'tmux -vL new-session run-shell "npm run dev"', // A pane command earlier in a sequence does not cover a later run-shell. 'tmux new-session -d \\; run-shell "npm run dev"', - 'tmux -L work new-session -d \\; run-shell "npm run dev"' + 'tmux -L work new-session -d \\; run-shell "npm run dev"', + // A `;` that ends an argument separates tmux commands too. + "tmux -L work new-session -d 'npm run dev;' run-shell 'npm run dev'" ]) { const result = runScript(command); assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); @@ -114,7 +116,8 @@ function runTests() { for (const command of [ 'tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"', - 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter' + 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter', + "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter" ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); From adf435d36f4ad2ef320305ca620e57803ae63f8c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 22:42:08 +0200 Subject: [PATCH 04/11] fix(hooks): find console.log in modified files from a subdirectory session check-console-log took the paths from `git diff --name-only HEAD`, which are relative to the repository root, and checked them with fs.existsSync from the hook's working directory. In a session started in a subdirectory, such as a monorepo package, no path existed from there, so the hook never warned. Resolve each path from the repository root. The exclusion patterns and the warning still use the root-relative path. --- scripts/hooks/check-console-log.js | 10 +++++++--- tests/hooks/hooks.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/check-console-log.js b/scripts/hooks/check-console-log.js index 28f3ac6db..03a5899f6 100755 --- a/scripts/hooks/check-console-log.js +++ b/scripts/hooks/check-console-log.js @@ -14,7 +14,8 @@ */ const fs = require('fs'); -const { isGitRepo, getGitModifiedFiles, readFile, log } = require('../lib/utils'); +const path = require('path'); +const { isGitRepo, getGitModifiedFiles, readFile, log, runCommand } = require('../lib/utils'); // Files where console.log is expected and should not trigger warnings const EXCLUDED_PATTERNS = [ @@ -66,14 +67,17 @@ process.stdin.on('end', () => { return; } + // git reports paths from the repository root, and the session can run in a + // subdirectory of it, so read each file from the root. + const root = runCommand('git rev-parse --show-toplevel').output; const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$']) - .filter(f => fs.existsSync(f)) + .filter(f => fs.existsSync(path.join(root, f))) .filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f))); let hasConsole = false; for (const file of files) { - const content = readFile(file); + const content = readFile(path.join(root, file)); if (content && content.includes('console.log')) { log(`[Hook] WARNING: console.log found in ${file}`); hasConsole = true; diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 899b05a69..71eb2e8ca 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1201,6 +1201,30 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('finds console.log in a modified file when the session runs in a subdirectory', async () => { + // git reports paths from the repository root, not from the session's directory. + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-console-log-')); + try { + const appDir = path.join(repo, 'packages', 'app'); + fs.mkdirSync(path.join(appDir, 'src'), { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: repo }); + fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\n'); + execFileSync('git', ['add', '-A'], { cwd: repo }); + execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], { cwd: repo }); + fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\nconsole.log(a);\n'); + + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn about the file, got: ${result.stderr}`); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + // session-end.js tests console.log('\nsession-end.js:'); From 5311485e940d6dd1ad163f42a13b408bb95aaaa0 Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 22:45:28 +0200 Subject: [PATCH 05/11] fix(hooks): keep a backslash in double quotes as bash does in the dev-server block readToken dropped every backslash inside double quotes, while bash drops it only before $, `, ", \\ or a newline. `"echo ready\;"` therefore reached the tmux check as `echo ready;`, read as a command separator, and `tmux send-keys -t dev "echo ready\;" "npm run dev" Enter` was blocked although it types into a pane. --- scripts/hooks/pre-bash-dev-server-block.js | 4 +++- tests/hooks/pre-bash-dev-server-block.test.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index ec86f112d..c5c336e68 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -56,7 +56,9 @@ function readToken(input, startIndex) { continue; } - if (ch === '\\' && quote === '"' && index + 1 < input.length) { + // Inside double quotes, bash drops a backslash only before $ ` " \ or a + // newline, so `"echo ready\;"` keeps its `\;` for tmux. + if (ch === '\\' && quote === '"' && index + 1 < input.length && '$`"\\\n'.includes(input[index + 1])) { token += input[index + 1]; index += 2; continue; diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 7427828f4..a5d47c8e2 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -117,7 +117,9 @@ function runTests() { 'tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"', 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter', - "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter" + "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter", + // In double quotes bash keeps the backslash before ;, so tmux reads a literal ; + 'tmux send-keys -t dev "echo ready\\;" "npm run dev" Enter' ]) { const result = runScript(command); assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); From b02e02dcd26c0bbe87db81be61b964a8167d692a Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 23:01:26 +0200 Subject: [PATCH 06/11] fix(hooks): list modified files from the repository root even with diff.relative With diff.relative=true, `git diff --name-only` reports paths relative to the working directory, so joining them to the repository root pointed at files that do not exist, and a session in a subdirectory again got no console.log warning. getGitModifiedFiles now runs `git -c diff.relative=false diff --name-only HEAD`. The -c form also works on Git versions older than --no-relative. --- scripts/lib/utils.js | 3 ++- tests/hooks/hooks.test.js | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 2e8550e5e..cc2b6c21e 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -552,7 +552,8 @@ function isGitRepo() { function getGitModifiedFiles(patterns = []) { if (!isGitRepo()) return []; - const result = runCommand('git diff --name-only HEAD'); + // Paths from the repository root even when diff.relative is set. + const result = runCommand('git -c diff.relative=false diff --name-only HEAD'); if (!result.success) return []; let files = result.output.split('\n').filter(Boolean); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 71eb2e8ca..af257f8e0 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1217,6 +1217,11 @@ async function runTests() { const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir); assert.strictEqual(result.code, 0); assert.ok(result.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn about the file, got: ${result.stderr}`); + + // diff.relative would make git report paths from the session's directory. + execFileSync('git', ['config', 'diff.relative', 'true'], { cwd: repo }); + const relative = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir); + assert.ok(relative.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn with diff.relative, got: ${relative.stderr}`); } finally { fs.rmSync(repo, { recursive: true, force: true }); } From cd1f5b74e2848e4da2ba41ee103e3f1ef2c7d603 Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 23:42:21 +0200 Subject: [PATCH 07/11] fix(hooks): match tsc diagnostics by the file they name The Stop typecheck reported every tsc line that contained the edited path anywhere. A root index.ts has the relative path `index.ts`, which every `src/index.ts(...)` diagnostic contains, so a clean root file was reported with the errors of src/index.ts. The same happened to src/utils.ts with web/src/utils.ts, and to any file named in another file's message. Compare the path that starts the diagnostic (`path(line,col): `), keeping the slash normalization for Windows. --- scripts/hooks/stop-format-typecheck.js | 10 ++++- tests/hooks/stop-format-typecheck.test.js | 51 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/stop-format-typecheck.js b/scripts/hooks/stop-format-typecheck.js index 25341ed44..27adec981 100644 --- a/scripts/hooks/stop-format-typecheck.js +++ b/scripts/hooks/stop-format-typecheck.js @@ -128,10 +128,16 @@ function findTsConfigDir(filePath) { return null; } +// 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`, and `src/utils.ts` those of `web/src/utils.ts`. function diagnosticMatchesFile(line, paths) { - const normalizedLine = line.replace(/\\/g, '/'); + const location = /^(.+?)\(\d+,\d+\): /.exec(line); + if (!location) return false; + const file = location[1].replace(/\\/g, '/'); for (const candidate of paths) { - if (normalizedLine.includes(candidate.replace(/\\/g, '/'))) return true; + if (file === candidate.replace(/\\/g, '/')) return true; } return false; } diff --git a/tests/hooks/stop-format-typecheck.test.js b/tests/hooks/stop-format-typecheck.test.js index 944c546a1..1e440254d 100644 --- a/tests/hooks/stop-format-typecheck.test.js +++ b/tests/hooks/stop-format-typecheck.test.js @@ -246,6 +246,57 @@ if (test('matches forward-slash TypeScript paths against Windows edited paths', })) passed++; else failed++; +if (test('matches a diagnostic by the file it names, not by a substring of the line', () => { + const rootIndex = new Set(['/project/index.ts', 'index.ts']); + assert.strictEqual(diagnosticMatchesFile('index.ts(1,14): error TS2322: Type', rootIndex), true); + assert.strictEqual(diagnosticMatchesFile('src/index.ts(1,14): error TS2322: Type', rootIndex), false); + const utils = new Set(['/project/src/utils.ts', 'src/utils.ts']); + assert.strictEqual(diagnosticMatchesFile('web/src/utils.ts(2,1): error TS2304: Cannot find name', utils), false); + assert.strictEqual(diagnosticMatchesFile("app.ts(3,1): error TS2307: Cannot find module './src/utils.ts'", utils), false); + assert.strictEqual(diagnosticMatchesFile('src/(group)/page.tsx(3,5): error TS2322', new Set(['src/(group)/page.tsx'])), true); + assert.strictEqual(diagnosticMatchesFile(" Type 'string' is not assignable to type 'number'.", utils), false); +})) passed++; else failed++; + +if (test('stop hook reports a tsc diagnostic only under the file it names', () => { + const { spawnSync } = require('child_process'); + const projectDir = fs.mkdtempSync(path.join(accumulatorTempRoot, 'ts-project-')); + const binDir = path.join(projectDir, 'bin'); + fs.mkdirSync(path.join(projectDir, 'src')); + fs.mkdirSync(binDir); + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); + fs.writeFileSync(path.join(projectDir, 'index.ts'), 'export const ok = 1;\n'); + fs.writeFileSync(path.join(projectDir, 'src', 'index.ts'), 'export const bad: number = "x";\n'); + const diagnostic = "src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'."; + const fakeTsc = path.join(binDir, 'fake-tsc.js'); + fs.writeFileSync(fakeTsc, `process.stdout.write(${JSON.stringify(`${diagnostic}\n`)});\nprocess.exitCode = 2;\n`); + if (process.platform === 'win32') { + fs.writeFileSync(path.join(binDir, 'npx.cmd'), `@echo off\r\nnode "${fakeTsc}" %*\r\n`); + } else { + fs.writeFileSync(path.join(binDir, 'npx'), `#!/usr/bin/env node\nrequire(${JSON.stringify(fakeTsc)});\n`); + fs.chmodSync(path.join(binDir, 'npx'), 0o755); + } + const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH'; + const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js'); + const stopStderr = editedFile => { + fs.writeFileSync(getAccumFile(), `${editedFile}\n`); + return spawnSync('node', [stopScript], { + input: '{}', + env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID, [pathKey]: `${binDir}${path.delimiter}${process.env[pathKey]}` }, + encoding: 'utf8', + timeout: 10000 + }).stderr; + }; + try { + const rootStderr = stopStderr(path.join(projectDir, 'index.ts')); + assert.ok(!rootStderr.includes('TypeScript errors'), `root index.ts must not get src/index.ts errors:\n${rootStderr}`); + const nestedStderr = stopStderr(path.join(projectDir, 'src', 'index.ts')); + assert.ok(nestedStderr.includes(`TypeScript errors in index.ts:\n${diagnostic}`), nestedStderr); + } finally { + cleanAccumFile(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } +})) 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'); From 10e7c5800fef43fafacc0d043e6d712ab054be5b Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 23:51:32 +0200 Subject: [PATCH 08/11] fix(hooks): recognize Prettier's TypeScript and JSON5 config files Prettier's config searcher also reads .prettierrc.json5, .prettierrc.ts/.mts/.cts and prettier.config.ts/.mts/.cts. resolve-formatter knew none of them, so a project configured only through prettier.config.ts was never formatted by post-edit-format, quality-gate or the Stop batch. config-protection's rc pattern stopped at .yaml/.toml, so an existing .prettierrc.ts or .prettierrc.json5 could be edited freely. --- scripts/hooks/config-protection.js | 5 +++-- scripts/lib/resolve-formatter.js | 11 ++++++++++- tests/hooks/config-protection.test.js | 19 +++++++++++++++++++ tests/lib/resolve-formatter.test.js | 15 +++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 75a7a0781..8dce8b2ea 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -117,8 +117,9 @@ const PROTECTED_FILES = new Set([ const PROTECTED_PATTERNS = [ // eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ... /^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i, - // .eslintrc.base.json, .prettierrc.shared.yml ... - /^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i, + // .eslintrc.base.json, .prettierrc.shared.yml ... Prettier also reads + // `.prettierrc.ts`, `.mts`, `.cts` and `.json5`. + /^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|ts|mts|cts|json|jsonc|json5|yml|yaml|toml)$/i, ]; function isProtectedName(basename) { diff --git a/scripts/lib/resolve-formatter.js b/scripts/lib/resolve-formatter.js index 3fec591c9..251264e78 100644 --- a/scripts/lib/resolve-formatter.js +++ b/scripts/lib/resolve-formatter.js @@ -32,7 +32,16 @@ const PRETTIER_CONFIGS = [ '.prettierrc.toml', 'prettier.config.js', 'prettier.config.cjs', - 'prettier.config.mjs' + 'prettier.config.mjs', + // The rest of the names Prettier's config searcher looks for + // (src/config/prettier-config/config-searcher.js). + '.prettierrc.json5', + '.prettierrc.ts', + '.prettierrc.mts', + '.prettierrc.cts', + 'prettier.config.ts', + 'prettier.config.mts', + 'prettier.config.cts' ]; const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS]; diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index b0b88ae54..9ab7d844f 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -447,6 +447,25 @@ function runTests() { }) ); + results.push( + test("blocks Prettier's TypeScript and JSON5 rc files", () => { + return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-prettier-')), tmpDir => { + for (const name of ['.prettierrc.ts', '.prettierrc.mts', '.prettierrc.cts', '.prettierrc.json5', '.prettierrc.shared.ts']) { + const absPath = path.join(tmpDir, name); + fs.writeFileSync(absPath, 'export default {};\n'); + + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: absPath } }); + + assert.strictEqual(result.code, 2, 'Expected ' + name + ' to be blocked'); + assert.ok( + result.stderr.includes('BLOCKED: Modifying ' + name + ' is not allowed.'), + 'Expected block message for ' + name + ', got: ' + result.stderr + ); + } + }); + }) + ); + results.push( test('does not block build or test tooling configs', () => { // Pins the boundary: this hook guards LINTER configs. A future widening diff --git a/tests/lib/resolve-formatter.test.js b/tests/lib/resolve-formatter.test.js index dc478fc84..42185725f 100644 --- a/tests/lib/resolve-formatter.test.js +++ b/tests/lib/resolve-formatter.test.js @@ -155,6 +155,21 @@ function runTests() { assert.strictEqual(detectFormatter(root), 'prettier'); }); + run("detectFormatter: detects Prettier's TypeScript and JSON5 config files", () => { + const names = ['.prettierrc.json5', '.prettierrc.ts', '.prettierrc.mts', '.prettierrc.cts', + 'prettier.config.ts', 'prettier.config.mts', 'prettier.config.cts']; + for (const name of names) { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test' })); + fs.writeFileSync(path.join(root, name), 'export default {};\n'); + assert.strictEqual(detectFormatter(root), 'prettier', name); + const nested = path.join(root, 'src'); + fs.mkdirSync(nested); + fs.rmSync(path.join(root, 'package.json')); + assert.strictEqual(findProjectRoot(nested), root, `${name} marks the project root`); + } + }); + run('detectFormatter: detects prettier key in package.json', () => { const root = makeTmpDir(); fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test', prettier: { singleQuote: true } })); From 0a7a9a718e86f090e789afe53102ab654f8f7b24 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:32:16 -0400 Subject: [PATCH 09/11] fix(hooks): use session repository for console warnings --- scripts/hooks/check-console-log.js | 19 ++++++++++++--- scripts/lib/utils.js | 14 ++++++----- tests/hooks/hooks.test.js | 39 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/scripts/hooks/check-console-log.js b/scripts/hooks/check-console-log.js index 03a5899f6..021d617ef 100755 --- a/scripts/hooks/check-console-log.js +++ b/scripts/hooks/check-console-log.js @@ -28,6 +28,18 @@ const EXCLUDED_PATTERNS = [ ]; const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; + +// This Stop hook inspects the session's working directory. Inherited GIT_* +// variables from another Git process can silently redirect discovery and diff +// commands to a different repository, so give every Git call the same clean env. +function sessionGitOptions() { + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (name.toUpperCase().startsWith('GIT_')) delete env[name]; + } + return { env }; +} + let data = ''; let stdinBytes = 0; let oversized = false; @@ -62,15 +74,16 @@ function passThroughAndExit() { process.stdin.on('end', () => { try { - if (!isGitRepo()) { + const gitOptions = sessionGitOptions(); + if (!isGitRepo(gitOptions)) { passThroughAndExit(); return; } // git reports paths from the repository root, and the session can run in a // subdirectory of it, so read each file from the root. - const root = runCommand('git rev-parse --show-toplevel').output; - const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$']) + const root = runCommand('git rev-parse --show-toplevel', gitOptions).output; + const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'], gitOptions) .filter(f => fs.existsSync(path.join(root, f))) .filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f))); diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index cc2b6c21e..416c1c21f 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -537,23 +537,25 @@ function runCommand(cmd, options = {}) { } /** - * Check if current directory is a git repository + * Check if current directory is a git repository. + * @param {object} [options] - Options forwarded to runCommand, including env. */ -function isGitRepo() { - return runCommand('git rev-parse --git-dir').success; +function isGitRepo(options = {}) { + return runCommand('git rev-parse --git-dir', options).success; } /** * Get git modified files, optionally filtered by regex patterns * @param {string[]} patterns - Array of regex pattern strings to filter files. * Invalid patterns are silently skipped. + * @param {object} [options] - Options forwarded to every Git subprocess. * @returns {string[]} Array of modified file paths */ -function getGitModifiedFiles(patterns = []) { - if (!isGitRepo()) return []; +function getGitModifiedFiles(patterns = [], options = {}) { + if (!isGitRepo(options)) return []; // Paths from the repository root even when diff.relative is set. - const result = runCommand('git -c diff.relative=false diff --name-only HEAD'); + const result = runCommand('git -c diff.relative=false diff --name-only HEAD', options); if (!result.success) return []; let files = result.output.split('\n').filter(Boolean); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index af257f8e0..2867737ea 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1230,6 +1230,45 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('uses the session repository despite inherited Git environment overrides', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-console-git-env-')); + try { + const makeRepo = (name, file) => { + const repo = path.join(root, name); + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: repo }); + fs.writeFileSync(path.join(repo, 'src', file), 'export const value = 1;\n'); + execFileSync('git', ['add', '-A'], { cwd: repo }); + execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], { cwd: repo }); + return repo; + }; + const sessionRepo = makeRepo('session', 'local.ts'); + const foreignRepo = makeRepo('foreign', 'foreign.ts'); + fs.writeFileSync(path.join(foreignRepo, 'src', 'foreign.ts'), 'console.log("foreign");\n'); + const env = { + GIT_DIR: path.join(foreignRepo, '.git'), + GIT_WORK_TREE: foreignRepo, + GIT_INDEX_FILE: path.join(foreignRepo, '.git', 'index') + }; + + const clean = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', env, sessionRepo); + assert.strictEqual(clean.code, 0); + assert.ok(!clean.stderr.includes('console.log found'), `foreign repository leaked into hook: ${clean.stderr}`); + + fs.writeFileSync(path.join(sessionRepo, 'src', 'local.ts'), 'console.log("local");\n'); + const local = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', env, sessionRepo); + assert.strictEqual(local.code, 0); + assert.ok(local.stderr.includes('console.log found in src/local.ts'), local.stderr); + assert.ok(!local.stderr.includes('foreign.ts'), local.stderr); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + // session-end.js tests console.log('\nsession-end.js:'); From e10f72413f842d1f1a2e230a2f44db60e6dae495 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:41:21 -0400 Subject: [PATCH 10/11] test(hooks): isolate Git fixtures from inherited env --- tests/hooks/hooks.test.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2867737ea..e31cdf449 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -233,6 +233,14 @@ function createTestDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-')); } +function fixtureGitOptions(cwd) { + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (name.toUpperCase().startsWith('GIT_')) delete env[name]; + } + return { cwd, env }; +} + // Clean up test directory function cleanupTestDir(testDir) { const retryableCodes = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']); @@ -1208,10 +1216,10 @@ async function runTests() { try { const appDir = path.join(repo, 'packages', 'app'); fs.mkdirSync(path.join(appDir, 'src'), { recursive: true }); - execFileSync('git', ['init', '-q'], { cwd: repo }); + execFileSync('git', ['init', '-q'], fixtureGitOptions(repo)); fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\n'); - execFileSync('git', ['add', '-A'], { cwd: repo }); - execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], { cwd: repo }); + execFileSync('git', ['add', '-A'], fixtureGitOptions(repo)); + execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], fixtureGitOptions(repo)); fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\nconsole.log(a);\n'); const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir); @@ -1219,7 +1227,7 @@ async function runTests() { assert.ok(result.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn about the file, got: ${result.stderr}`); // diff.relative would make git report paths from the session's directory. - execFileSync('git', ['config', 'diff.relative', 'true'], { cwd: repo }); + execFileSync('git', ['config', 'diff.relative', 'true'], fixtureGitOptions(repo)); const relative = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir); assert.ok(relative.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn with diff.relative, got: ${relative.stderr}`); } finally { @@ -1237,10 +1245,10 @@ async function runTests() { const makeRepo = (name, file) => { const repo = path.join(root, name); fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); - execFileSync('git', ['init', '-q'], { cwd: repo }); + execFileSync('git', ['init', '-q'], fixtureGitOptions(repo)); fs.writeFileSync(path.join(repo, 'src', file), 'export const value = 1;\n'); - execFileSync('git', ['add', '-A'], { cwd: repo }); - execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], { cwd: repo }); + execFileSync('git', ['add', '-A'], fixtureGitOptions(repo)); + execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], fixtureGitOptions(repo)); return repo; }; const sessionRepo = makeRepo('session', 'local.ts'); From b144908df64c67ee7c01036eb95ec732234974dc Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:43:37 -0400 Subject: [PATCH 11/11] fix(types): declare Git utility options --- scripts/lib/utils.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 9e980c699..df9bb8aad 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -193,10 +193,10 @@ export interface CommandResult { export function runCommand(cmd: string, options?: ExecSyncOptions): CommandResult; /** Check if the current directory is inside a git repository */ -export function isGitRepo(): boolean; +export function isGitRepo(options?: ExecSyncOptions): boolean; /** * Get git modified files (staged + unstaged), optionally filtered by regex patterns. * Invalid regex patterns are silently skipped. */ -export function getGitModifiedFiles(patterns?: string[]): string[]; +export function getGitModifiedFiles(patterns?: string[], options?: ExecSyncOptions): string[];