diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 2f1d0334b..3e3bb554f 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -7,88 +7,54 @@ * Runs after Edit tool use. If the edited file is a JS/TS file, * auto-detects the project formatter (Biome or Prettier) by looking * for config files, then formats accordingly. + * + * For Biome, uses `check --write` (format + lint in one pass) to + * avoid a redundant second invocation from quality-gate.js. + * + * Prefers the local node_modules/.bin binary over npx to skip + * package-resolution overhead (~200-500ms savings per invocation). + * * Fails silently if no formatter is found or installed. */ const { execFileSync } = require('child_process'); -const fs = require('fs'); const path = require('path'); -const MAX_STDIN = 1024 * 1024; // 1MB limit -let data = ''; -process.stdin.setEncoding('utf8'); - -process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); - } -}); - -function findProjectRoot(startDir) { - let dir = startDir; - while (dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, 'package.json'))) return dir; - dir = path.dirname(dir); - } - return startDir; -} - -function detectFormatter(projectRoot) { - const biomeConfigs = ['biome.json', 'biome.jsonc']; - for (const cfg of biomeConfigs) { - if (fs.existsSync(path.join(projectRoot, cfg))) return 'biome'; - } - - const prettierConfigs = [ - '.prettierrc', - '.prettierrc.json', - '.prettierrc.js', - '.prettierrc.cjs', - '.prettierrc.mjs', - '.prettierrc.yml', - '.prettierrc.yaml', - '.prettierrc.toml', - 'prettier.config.js', - 'prettier.config.cjs', - 'prettier.config.mjs', - ]; - for (const cfg of prettierConfigs) { - if (fs.existsSync(path.join(projectRoot, cfg))) return 'prettier'; - } - - return null; -} +const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter'); -function getFormatterCommand(formatter, filePath) { - const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; - if (formatter === 'biome') { - return { bin: npxBin, args: ['@biomejs/biome', 'format', '--write', filePath] }; - } - if (formatter === 'prettier') { - return { bin: npxBin, args: ['prettier', '--write', filePath] }; - } - return null; -} +const MAX_STDIN = 1024 * 1024; // 1MB limit -process.stdin.on('end', () => { +/** + * Core logic — exported so run-with-flags.js can call directly + * without spawning a child process. + * + * @param {string} rawInput - Raw JSON string from stdin + * @returns {string} The original input (pass-through) + */ +function run(rawInput) { try { - const input = JSON.parse(data); + const input = JSON.parse(rawInput); const filePath = input.tool_input?.file_path; if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) { try { - const projectRoot = findProjectRoot(path.dirname(path.resolve(filePath))); + const resolvedFilePath = path.resolve(filePath); + const projectRoot = findProjectRoot(path.dirname(resolvedFilePath)); const formatter = detectFormatter(projectRoot); - const cmd = getFormatterCommand(formatter, filePath); + if (!formatter) return rawInput; + + const resolved = resolveFormatterBin(projectRoot, formatter); + if (!resolved) return rawInput; + + // Biome: `check --write` = format + lint in one pass + // Prettier: `--write` = format only + const args = formatter === 'biome' ? [...resolved.prefix, 'check', '--write', resolvedFilePath] : [...resolved.prefix, '--write', resolvedFilePath]; - if (cmd) { - execFileSync(cmd.bin, cmd.args, { - cwd: projectRoot, - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 15000 - }); - } + execFileSync(resolved.bin, args, { + cwd: projectRoot, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000 + }); } catch { // Formatter not installed, file missing, or failed — non-blocking } @@ -97,6 +63,26 @@ process.stdin.on('end', () => { // Invalid input — pass through } - process.stdout.write(data); - process.exit(0); -}); + return rawInput; +} + +// ── stdin entry point (backwards-compatible) ──────────────────── +if (require.main === module) { + let data = ''; + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', chunk => { + if (data.length < MAX_STDIN) { + const remaining = MAX_STDIN - data.length; + data += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + const result = run(data); + process.stdout.write(result); + process.exit(0); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/quality-gate.js b/scripts/hooks/quality-gate.js index a6f180f0a..19f351950 100755 --- a/scripts/hooks/quality-gate.js +++ b/scripts/hooks/quality-gate.js @@ -5,6 +5,11 @@ * Runs lightweight quality checks after file edits. * - Targets one file when file_path is provided * - Falls back to no-op when language/tooling is unavailable + * + * For JS/TS files with Biome, this hook is skipped because + * post-edit-format.js already runs `biome check --write`. + * This hook still handles .json/.md files for Biome, and all + * Prettier / Go / Python checks. */ 'use strict'; @@ -13,56 +18,105 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter'); + const MAX_STDIN = 1024 * 1024; -let raw = ''; -function run(command, args, cwd = process.cwd()) { +/** + * Execute a command synchronously, returning the spawnSync result. + * + * @param {string} command - Executable path or name + * @param {string[]} args - Arguments to pass + * @param {string} [cwd] - Working directory (defaults to process.cwd()) + * @returns {import('child_process').SpawnSyncReturns} + */ +function exec(command, args, cwd = process.cwd()) { return spawnSync(command, args, { cwd, encoding: 'utf8', env: process.env, + timeout: 15000 }); } +/** + * Write a message to stderr for logging. + * + * @param {string} msg - Message to log + */ function log(msg) { process.stderr.write(`${msg}\n`); } +/** + * Run quality-gate checks for a single file based on its extension. + * Skips JS/TS files when Biome is configured (handled by post-edit-format). + * + * @param {string} filePath - Path to the edited file + */ function maybeRunQualityGate(filePath) { if (!filePath || !fs.existsSync(filePath)) { return; } + // Resolve to absolute path so projectRoot-relative comparisons work + filePath = path.resolve(filePath); + const ext = path.extname(filePath).toLowerCase(); const fix = String(process.env.ECC_QUALITY_GATE_FIX || '').toLowerCase() === 'true'; const strict = String(process.env.ECC_QUALITY_GATE_STRICT || '').toLowerCase() === 'true'; if (['.ts', '.tsx', '.js', '.jsx', '.json', '.md'].includes(ext)) { - // Prefer biome if present - if (fs.existsSync(path.join(process.cwd(), 'biome.json')) || fs.existsSync(path.join(process.cwd(), 'biome.jsonc'))) { - const args = ['biome', 'check', filePath]; + const projectRoot = findProjectRoot(path.dirname(path.resolve(filePath))); + const formatter = detectFormatter(projectRoot); + + if (formatter === 'biome') { + // JS/TS already handled by post-edit-format via `biome check --write` + if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { + return; + } + + // .json / .md — still need quality gate + const resolved = resolveFormatterBin(projectRoot, 'biome'); + if (!resolved) return; + const args = [...resolved.prefix, 'check', filePath]; if (fix) args.push('--write'); - const result = run('npx', args); + const result = exec(resolved.bin, args, projectRoot); if (result.status !== 0 && strict) { log(`[QualityGate] Biome check failed for ${filePath}`); } return; } - // Fallback to prettier when installed - const prettierArgs = ['prettier', '--check', filePath]; - if (fix) { - prettierArgs[1] = '--write'; - } - const prettier = run('npx', prettierArgs); - if (prettier.status !== 0 && strict) { - log(`[QualityGate] Prettier check failed for ${filePath}`); + if (formatter === 'prettier') { + const resolved = resolveFormatterBin(projectRoot, 'prettier'); + if (!resolved) return; + const args = [...resolved.prefix, fix ? '--write' : '--check', filePath]; + const result = exec(resolved.bin, args, projectRoot); + if (result.status !== 0 && strict) { + log(`[QualityGate] Prettier check failed for ${filePath}`); + } + return; } + + // No formatter configured — skip return; } - if (ext === '.go' && fix) { - run('gofmt', ['-w', filePath]); + if (ext === '.go') { + if (fix) { + const r = exec('gofmt', ['-w', filePath]); + if (r.status !== 0 && strict) { + log(`[QualityGate] gofmt failed for ${filePath}`); + } + } else if (strict) { + const r = exec('gofmt', ['-l', filePath]); + if (r.status !== 0) { + log(`[QualityGate] gofmt failed for ${filePath}`); + } else if (r.stdout && r.stdout.trim()) { + log(`[QualityGate] gofmt check failed for ${filePath}`); + } + } return; } @@ -70,29 +124,45 @@ function maybeRunQualityGate(filePath) { const args = ['format']; if (!fix) args.push('--check'); args.push(filePath); - const r = run('ruff', args); + const r = exec('ruff', args); if (r.status !== 0 && strict) { log(`[QualityGate] Ruff check failed for ${filePath}`); } } } -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); - } -}); - -process.stdin.on('end', () => { +/** + * Core logic — exported so run-with-flags.js can call directly. + * + * @param {string} rawInput - Raw JSON string from stdin + * @returns {string} The original input (pass-through) + */ +function run(rawInput) { try { - const input = JSON.parse(raw); + const input = JSON.parse(rawInput); const filePath = String(input.tool_input?.file_path || ''); maybeRunQualityGate(filePath); } catch { // Ignore parse errors. } + return rawInput; +} + +// ── stdin entry point (backwards-compatible) ──────────────────── +if (require.main === module) { + let raw = ''; + 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); + } + }); + + process.stdin.on('end', () => { + const result = run(raw); + process.stdout.write(result); + }); +} - process.stdout.write(raw); -}); +module.exports = { run }; diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index eb41564fa..d69e8131a 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -52,7 +52,15 @@ async function main() { } const pluginRoot = getPluginRoot(); - const scriptPath = path.join(pluginRoot, relScriptPath); + const resolvedRoot = path.resolve(pluginRoot); + const scriptPath = path.resolve(pluginRoot, relScriptPath); + + // Prevent path traversal outside the plugin root + if (!scriptPath.startsWith(resolvedRoot + path.sep)) { + process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`); + process.stdout.write(raw); + process.exit(0); + } if (!fs.existsSync(scriptPath)) { process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`); @@ -60,11 +68,43 @@ async function main() { process.exit(0); } + // Prefer direct require() when the hook exports a run(rawInput) function. + // This eliminates one Node.js process spawn (~50-100ms savings per hook). + // + // SAFETY: Only require() hooks that export run(). Legacy hooks execute + // side effects at module scope (stdin listeners, process.exit, main() calls) + // which would interfere with the parent process or cause double execution. + let hookModule; + const src = fs.readFileSync(scriptPath, 'utf8'); + const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src); + + if (hasRunExport) { + try { + hookModule = require(scriptPath); + } catch (requireErr) { + process.stderr.write(`[Hook] require() failed for ${hookId}: ${requireErr.message}\n`); + // Fall through to legacy spawnSync path + } + } + + if (hookModule && typeof hookModule.run === 'function') { + try { + const output = hookModule.run(raw); + if (output != null) process.stdout.write(output); + } catch (runErr) { + process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`); + process.stdout.write(raw); + } + process.exit(0); + } + + // Legacy path: spawn a child Node process for hooks without run() export const result = spawnSync('node', [scriptPath], { input: raw, encoding: 'utf8', env: process.env, cwd: process.cwd(), + timeout: 30000 }); if (result.stdout) process.stdout.write(result.stdout); diff --git a/scripts/lib/resolve-formatter.js b/scripts/lib/resolve-formatter.js new file mode 100644 index 000000000..562dd956c --- /dev/null +++ b/scripts/lib/resolve-formatter.js @@ -0,0 +1,160 @@ +/** + * Shared formatter resolution utilities with caching. + * + * Extracts project-root discovery, formatter detection, and binary + * resolution into a single module so that post-edit-format.js and + * quality-gate.js avoid duplicating work and filesystem lookups. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ── Caches (per-process, cleared on next hook invocation) ─────────── +const projectRootCache = new Map(); +const formatterCache = new Map(); +const binCache = new Map(); + +// ── Public helpers ────────────────────────────────────────────────── + +/** + * Walk up from `startDir` until a directory containing package.json is found. + * Returns `startDir` as fallback when no package.json exists above it. + * + * @param {string} startDir - Absolute directory path to start from + * @returns {string} Absolute path to the project root + */ +function findProjectRoot(startDir) { + if (projectRootCache.has(startDir)) return projectRootCache.get(startDir); + + let dir = startDir; + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + projectRootCache.set(startDir, dir); + return dir; + } + dir = path.dirname(dir); + } + + projectRootCache.set(startDir, startDir); + return startDir; +} + +/** + * Detect the formatter configured in the project. + * Biome takes priority over Prettier. + * + * @param {string} projectRoot - Absolute path to the project root + * @returns {'biome' | 'prettier' | null} + */ +function detectFormatter(projectRoot) { + if (formatterCache.has(projectRoot)) return formatterCache.get(projectRoot); + + const biomeConfigs = ['biome.json', 'biome.jsonc']; + for (const cfg of biomeConfigs) { + if (fs.existsSync(path.join(projectRoot, cfg))) { + formatterCache.set(projectRoot, 'biome'); + return 'biome'; + } + } + + // Check package.json "prettier" key before config files + try { + const pkgPath = path.join(projectRoot, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.prettier != null) { + formatterCache.set(projectRoot, 'prettier'); + return 'prettier'; + } + } + } catch { + // Malformed package.json — continue to file-based detection + } + + const prettierConfigs = [ + '.prettierrc', + '.prettierrc.json', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.mjs', + '.prettierrc.yml', + '.prettierrc.yaml', + '.prettierrc.toml', + 'prettier.config.js', + 'prettier.config.cjs', + 'prettier.config.mjs' + ]; + for (const cfg of prettierConfigs) { + if (fs.existsSync(path.join(projectRoot, cfg))) { + formatterCache.set(projectRoot, 'prettier'); + return 'prettier'; + } + } + + formatterCache.set(projectRoot, null); + return null; +} + +/** + * Resolve the formatter binary, preferring the local node_modules/.bin + * installation over npx to avoid package-resolution overhead. + * + * @param {string} projectRoot - Absolute path to the project root + * @param {'biome' | 'prettier'} formatter - Detected formatter name + * @returns {{ bin: string, prefix: string[] } | null} + * `bin` – executable path (absolute local path or npx/npx.cmd) + * `prefix` – extra args to prepend (e.g. ['@biomejs/biome'] when using npx) + */ +function resolveFormatterBin(projectRoot, formatter) { + const cacheKey = `${projectRoot}:${formatter}`; + if (binCache.has(cacheKey)) return binCache.get(cacheKey); + + const isWin = process.platform === 'win32'; + const npxBin = isWin ? 'npx.cmd' : 'npx'; + + if (formatter === 'biome') { + const localBin = path.join(projectRoot, 'node_modules', '.bin', isWin ? 'biome.cmd' : 'biome'); + if (fs.existsSync(localBin)) { + const result = { bin: localBin, prefix: [] }; + binCache.set(cacheKey, result); + return result; + } + const result = { bin: npxBin, prefix: ['@biomejs/biome'] }; + binCache.set(cacheKey, result); + return result; + } + + if (formatter === 'prettier') { + const localBin = path.join(projectRoot, 'node_modules', '.bin', isWin ? 'prettier.cmd' : 'prettier'); + if (fs.existsSync(localBin)) { + const result = { bin: localBin, prefix: [] }; + binCache.set(cacheKey, result); + return result; + } + const result = { bin: npxBin, prefix: ['prettier'] }; + binCache.set(cacheKey, result); + return result; + } + + // Unknown formatter — return null so callers can handle gracefully + binCache.set(cacheKey, null); + return null; +} + +/** + * Clear all caches. Useful for testing. + */ +function clearCaches() { + projectRootCache.clear(); + formatterCache.clear(); + binCache.clear(); +} + +module.exports = { + findProjectRoot, + detectFormatter, + resolveFormatterBin, + clearCaches +}; diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2c8732042..9029faaed 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -47,8 +47,8 @@ function runScript(scriptPath, input = '', env = {}) { let stdout = ''; let stderr = ''; - proc.stdout.on('data', data => stdout += data); - proc.stderr.on('data', data => stderr += data); + proc.stdout.on('data', data => (stdout += data)); + proc.stderr.on('data', data => (stderr += data)); if (input) { proc.stdin.write(input); @@ -87,1446 +87,1746 @@ async function runTests() { // session-start.js tests console.log('session-start.js:'); - if (await asyncTest('runs without error', async () => { - const result = await runScript(path.join(scriptsDir, 'session-start.js')); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - })) passed++; else failed++; - - if (await asyncTest('outputs session info to stderr', async () => { - const result = await runScript(path.join(scriptsDir, 'session-start.js')); - assert.ok( - result.stderr.includes('[SessionStart]') || - result.stderr.includes('Package manager'), - 'Should output session info' - ); - })) passed++; else failed++; + if ( + await asyncTest('runs without error', async () => { + const result = await runScript(path.join(scriptsDir, 'session-start.js')); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('outputs session info to stderr', async () => { + const result = await runScript(path.join(scriptsDir, 'session-start.js')); + assert.ok(result.stderr.includes('[SessionStart]') || result.stderr.includes('Package manager'), 'Should output session info'); + }) + ) + passed++; + else failed++; // session-start.js edge cases console.log('\nsession-start.js (edge cases):'); - if (await asyncTest('exits 0 even with isolated empty HOME', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (await asyncTest('reports package manager detection', async () => { - const result = await runScript(path.join(scriptsDir, 'session-start.js')); - assert.ok( - result.stderr.includes('Package manager') || result.stderr.includes('[SessionStart]'), - 'Should report package manager info' - ); - })) passed++; else failed++; - - if (await asyncTest('skips template session content', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Create a session file with template placeholder - const sessionFile = path.join(sessionsDir, '2026-02-11-abcd1234-session.tmp'); - fs.writeFileSync(sessionFile, '## Current State\n\n[Session context goes here]\n'); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - // stdout should NOT contain the template content - assert.ok( - !result.stdout.includes('Previous session summary'), - 'Should not inject template session content' - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (await asyncTest('injects real session content', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Create a real session file - const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp'); - fs.writeFileSync(sessionFile, '# Real Session\n\nI worked on authentication refactor.\n'); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - assert.ok( - result.stdout.includes('Previous session summary'), - 'Should inject real session content' - ); - assert.ok( - result.stdout.includes('authentication refactor'), - 'Should include session content text' - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (await asyncTest('reports learned skills count', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`); - const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned'); - fs.mkdirSync(learnedDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); - - // Create learned skill files - fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing'); - fs.writeFileSync(path.join(learnedDir, 'debugging.md'), '# Debugging'); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - assert.ok( - result.stderr.includes('2 learned skill(s)'), - `Should report 2 learned skills, stderr: ${result.stderr}` - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('exits 0 even with isolated empty HOME', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`); + fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('reports package manager detection', async () => { + const result = await runScript(path.join(scriptsDir, 'session-start.js')); + assert.ok(result.stderr.includes('Package manager') || result.stderr.includes('[SessionStart]'), 'Should report package manager info'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('skips template session content', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Create a session file with template placeholder + const sessionFile = path.join(sessionsDir, '2026-02-11-abcd1234-session.tmp'); + fs.writeFileSync(sessionFile, '## Current State\n\n[Session context goes here]\n'); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + // stdout should NOT contain the template content + assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject template session content'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('injects real session content', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Create a real session file + const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp'); + fs.writeFileSync(sessionFile, '# Real Session\n\nI worked on authentication refactor.\n'); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content'); + assert.ok(result.stdout.includes('authentication refactor'), 'Should include session content text'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('reports learned skills count', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`); + const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned'); + fs.mkdirSync(learnedDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + + // Create learned skill files + fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing'); + fs.writeFileSync(path.join(learnedDir, 'debugging.md'), '# Debugging'); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('2 learned skill(s)'), `Should report 2 learned skills, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // check-console-log.js tests console.log('\ncheck-console-log.js:'); - if (await asyncTest('passes through stdin data to stdout', async () => { - const stdinData = JSON.stringify({ tool_name: 'Write', tool_input: {} }); - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('tool_name'), 'Should pass through stdin data'); - })) passed++; else failed++; - - if (await asyncTest('exits 0 with empty stdin', async () => { - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); - assert.strictEqual(result.code, 0); - })) passed++; else failed++; - - if (await asyncTest('handles invalid JSON stdin gracefully', async () => { - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), 'not valid json'); - assert.strictEqual(result.code, 0, 'Should exit 0 on invalid JSON'); - // Should still pass through the data - assert.ok(result.stdout.includes('not valid json'), 'Should pass through invalid data'); - })) passed++; else failed++; + if ( + await asyncTest('passes through stdin data to stdout', async () => { + const stdinData = JSON.stringify({ tool_name: 'Write', tool_input: {} }); + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('tool_name'), 'Should pass through stdin data'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exits 0 with empty stdin', async () => { + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); + assert.strictEqual(result.code, 0); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles invalid JSON stdin gracefully', async () => { + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), 'not valid json'); + assert.strictEqual(result.code, 0, 'Should exit 0 on invalid JSON'); + // Should still pass through the data + assert.ok(result.stdout.includes('not valid json'), 'Should pass through invalid data'); + }) + ) + passed++; + else failed++; // session-end.js tests console.log('\nsession-end.js:'); - if (await asyncTest('runs without error', async () => { - const result = await runScript(path.join(scriptsDir, 'session-end.js')); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - })) passed++; else failed++; - - if (await asyncTest('creates or updates session file', async () => { - // Run the script - await runScript(path.join(scriptsDir, 'session-end.js')); - - // Check if session file was created - // Note: Without CLAUDE_SESSION_ID, falls back to project name (not 'default') - // Use local time to match the script's getDateString() function - const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); - const now = new Date(); - const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - - // Get the expected session ID (project name fallback) - const utils = require('../../scripts/lib/utils'); - const expectedId = utils.getSessionIdShort(); - const sessionFile = path.join(sessionsDir, `${today}-${expectedId}-session.tmp`); - - assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); - })) passed++; else failed++; - - if (await asyncTest('includes session ID in filename', async () => { - const testSessionId = 'test-session-abc12345'; - const expectedShortId = 'abc12345'; // Last 8 chars - - // Run with custom session ID - await runScript(path.join(scriptsDir, 'session-end.js'), '', { - CLAUDE_SESSION_ID: testSessionId - }); + if ( + await asyncTest('runs without error', async () => { + const result = await runScript(path.join(scriptsDir, 'session-end.js')); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('creates or updates session file', async () => { + // Run the script + await runScript(path.join(scriptsDir, 'session-end.js')); + + // Check if session file was created + // Note: Without CLAUDE_SESSION_ID, falls back to project name (not 'default') + // Use local time to match the script's getDateString() function + const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + // Get the expected session ID (project name fallback) + const utils = require('../../scripts/lib/utils'); + const expectedId = utils.getSessionIdShort(); + const sessionFile = path.join(sessionsDir, `${today}-${expectedId}-session.tmp`); + + assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('includes session ID in filename', async () => { + const testSessionId = 'test-session-abc12345'; + const expectedShortId = 'abc12345'; // Last 8 chars + + // Run with custom session ID + await runScript(path.join(scriptsDir, 'session-end.js'), '', { + CLAUDE_SESSION_ID: testSessionId + }); - // Check if session file was created with session ID - // Use local time to match the script's getDateString() function - const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); - const now = new Date(); - const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); + // Check if session file was created with session ID + // Use local time to match the script's getDateString() function + const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); - assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); - })) passed++; else failed++; + assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); + }) + ) + passed++; + else failed++; // pre-compact.js tests console.log('\npre-compact.js:'); - if (await asyncTest('runs without error', async () => { - const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - })) passed++; else failed++; - - if (await asyncTest('outputs PreCompact message', async () => { - const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); - assert.ok(result.stderr.includes('[PreCompact]'), 'Should output PreCompact message'); - })) passed++; else failed++; - - if (await asyncTest('creates compaction log', async () => { - await runScript(path.join(scriptsDir, 'pre-compact.js')); - const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt'); - assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); - })) passed++; else failed++; - - if (await asyncTest('annotates active session file with compaction marker', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-annotate-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - // 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'); - - try { - await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - - const content = fs.readFileSync(sessionFile, 'utf8'); - assert.ok( - content.includes('Compaction occurred'), - 'Should annotate the session file with compaction marker' - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (await asyncTest('compaction log contains timestamp', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-ts-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + if ( + await asyncTest('runs without error', async () => { + const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('outputs PreCompact message', async () => { + const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); + assert.ok(result.stderr.includes('[PreCompact]'), 'Should output PreCompact message'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('creates compaction log', async () => { + await runScript(path.join(scriptsDir, 'pre-compact.js')); + const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt'); + assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('annotates active session file with compaction marker', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-annotate-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // 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'); + + try { + await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); - try { - await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); + const content = fs.readFileSync(sessionFile, 'utf8'); + assert.ok(content.includes('Compaction occurred'), 'Should annotate the session file with compaction marker'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('compaction log contains timestamp', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-ts-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + try { + await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); - const logFile = path.join(sessionsDir, 'compaction-log.txt'); - assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); - const content = fs.readFileSync(logFile, 'utf8'); - // Should have a timestamp like [2026-02-11 14:30:00] - assert.ok( - /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/.test(content), - `Log should contain timestamped entry, got: ${content.substring(0, 100)}` - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + const logFile = path.join(sessionsDir, 'compaction-log.txt'); + assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); + const content = fs.readFileSync(logFile, 'utf8'); + // Should have a timestamp like [2026-02-11 14:30:00] + assert.ok(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/.test(content), `Log should contain timestamped entry, got: ${content.substring(0, 100)}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // suggest-compact.js tests console.log('\nsuggest-compact.js:'); - if (await asyncTest('runs without error', async () => { - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: 'test-session-' + Date.now() - }); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - })) passed++; else failed++; - - if (await asyncTest('increments counter on each call', async () => { - const sessionId = 'test-counter-' + Date.now(); - - // Run multiple times - for (let i = 0; i < 3; i++) { - await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId + if ( + await asyncTest('runs without error', async () => { + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: 'test-session-' + Date.now() }); - } + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('increments counter on each call', async () => { + const sessionId = 'test-counter-' + Date.now(); + + // Run multiple times + for (let i = 0; i < 3; i++) { + await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + } - // Check counter file - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(count, 3, `Counter should be 3, got ${count}`); + // Check counter file + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(count, 3, `Counter should be 3, got ${count}`); - // Cleanup - fs.unlinkSync(counterFile); - })) passed++; else failed++; + // Cleanup + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('suggests compact at threshold', async () => { - const sessionId = 'test-threshold-' + Date.now(); - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + if ( + await asyncTest('suggests compact at threshold', async () => { + const sessionId = 'test-threshold-' + Date.now(); + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - // Set counter to threshold - 1 - fs.writeFileSync(counterFile, '49'); + // Set counter to threshold - 1 + fs.writeFileSync(counterFile, '49'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '50' - }); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '50' + }); - assert.ok( - result.stderr.includes('50 tool calls reached'), - 'Should suggest compact at threshold' - ); + assert.ok(result.stderr.includes('50 tool calls reached'), 'Should suggest compact at threshold'); - // Cleanup - fs.unlinkSync(counterFile); - })) passed++; else failed++; + // Cleanup + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('does not suggest below threshold', async () => { - const sessionId = 'test-below-' + Date.now(); - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + if ( + await asyncTest('does not suggest below threshold', async () => { + const sessionId = 'test-below-' + Date.now(); + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - fs.writeFileSync(counterFile, '10'); + fs.writeFileSync(counterFile, '10'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '50' - }); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '50' + }); - assert.ok( - !result.stderr.includes('tool calls'), - 'Should not suggest compact below threshold' - ); + assert.ok(!result.stderr.includes('tool calls'), 'Should not suggest compact below threshold'); - fs.unlinkSync(counterFile); - })) passed++; else failed++; + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('suggests at regular intervals after threshold', async () => { - const sessionId = 'test-interval-' + Date.now(); - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + if ( + await asyncTest('suggests at regular intervals after threshold', async () => { + const sessionId = 'test-interval-' + Date.now(); + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - // Set counter to 74 (next will be 75, which is >50 and 75%25==0) - fs.writeFileSync(counterFile, '74'); + // Set counter to 74 (next will be 75, which is >50 and 75%25==0) + fs.writeFileSync(counterFile, '74'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '50' - }); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '50' + }); - assert.ok( - result.stderr.includes('75 tool calls'), - 'Should suggest at 25-call intervals after threshold' - ); + assert.ok(result.stderr.includes('75 tool calls'), 'Should suggest at 25-call intervals after threshold'); - fs.unlinkSync(counterFile); - })) passed++; else failed++; + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('handles corrupted counter file', async () => { - const sessionId = 'test-corrupt-' + Date.now(); - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + if ( + await asyncTest('handles corrupted counter file', async () => { + const sessionId = 'test-corrupt-' + Date.now(); + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - fs.writeFileSync(counterFile, 'not-a-number'); + fs.writeFileSync(counterFile, 'not-a-number'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); - assert.strictEqual(result.code, 0, 'Should handle corrupted counter gracefully'); + assert.strictEqual(result.code, 0, 'Should handle corrupted counter gracefully'); - // Counter should be reset to 1 - const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(newCount, 1, 'Should reset counter to 1 on corrupt data'); + // Counter should be reset to 1 + const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(newCount, 1, 'Should reset counter to 1 on corrupt data'); - fs.unlinkSync(counterFile); - })) passed++; else failed++; + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('uses default session ID when no env var', async () => { - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: '' // Empty, should use 'default' - }); + if ( + await asyncTest('uses default session ID when no env var', async () => { + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: '' // Empty, should use 'default' + }); - assert.strictEqual(result.code, 0, 'Should work with default session ID'); + assert.strictEqual(result.code, 0, 'Should work with default session ID'); - // Cleanup the default counter file - const counterFile = path.join(os.tmpdir(), 'claude-tool-count-default'); - if (fs.existsSync(counterFile)) fs.unlinkSync(counterFile); - })) passed++; else failed++; + // Cleanup the default counter file + const counterFile = path.join(os.tmpdir(), 'claude-tool-count-default'); + if (fs.existsSync(counterFile)) fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; - if (await asyncTest('validates threshold bounds', async () => { - const sessionId = 'test-bounds-' + Date.now(); - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + if ( + await asyncTest('validates threshold bounds', async () => { + const sessionId = 'test-bounds-' + Date.now(); + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - // Invalid threshold should fall back to 50 - fs.writeFileSync(counterFile, '49'); + // Invalid threshold should fall back to 50 + fs.writeFileSync(counterFile, '49'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '-5' // Invalid: negative - }); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '-5' // Invalid: negative + }); - assert.ok( - result.stderr.includes('50 tool calls'), - 'Should use default threshold (50) for invalid value' - ); + assert.ok(result.stderr.includes('50 tool calls'), 'Should use default threshold (50) for invalid value'); - fs.unlinkSync(counterFile); - })) passed++; else failed++; + fs.unlinkSync(counterFile); + }) + ) + passed++; + else failed++; // evaluate-session.js tests console.log('\nevaluate-session.js:'); - if (await asyncTest('runs without error when no transcript', async () => { - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js')); - assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); - })) passed++; else failed++; + if ( + await asyncTest('runs without error when no transcript', async () => { + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js')); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + }) + ) + passed++; + else failed++; - if (await asyncTest('skips short sessions', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + if ( + await asyncTest('skips short sessions', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Create a short transcript (less than 10 user messages) - const transcript = Array(5).fill('{"type":"user","content":"test"}\n').join(''); - fs.writeFileSync(transcriptPath, transcript); + // Create a short transcript (less than 10 user messages) + const transcript = Array(5).fill('{"type":"user","content":"test"}\n').join(''); + fs.writeFileSync(transcriptPath, transcript); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.ok( - result.stderr.includes('Session too short'), - 'Should indicate session is too short' - ); + assert.ok(result.stderr.includes('Session too short'), 'Should indicate session is too short'); - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - if (await asyncTest('processes sessions with enough messages', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + if ( + await asyncTest('processes sessions with enough messages', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Create a longer transcript (more than 10 user messages) - const transcript = Array(15).fill('{"type":"user","content":"test"}\n').join(''); - fs.writeFileSync(transcriptPath, transcript); + // Create a longer transcript (more than 10 user messages) + const transcript = Array(15).fill('{"type":"user","content":"test"}\n').join(''); + fs.writeFileSync(transcriptPath, transcript); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.ok( - result.stderr.includes('15 messages'), - 'Should report message count' - ); + assert.ok(result.stderr.includes('15 messages'), 'Should report message count'); - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // evaluate-session.js: whitespace tolerance regression test - if (await asyncTest('counts user messages with whitespace in JSON (regression)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Create transcript with whitespace around colons (pretty-printed style) - const lines = []; - for (let i = 0; i < 15; i++) { - lines.push('{ "type" : "user", "content": "message ' + i + '" }'); - } - fs.writeFileSync(transcriptPath, lines.join('\n')); + if ( + await asyncTest('counts user messages with whitespace in JSON (regression)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // Create transcript with whitespace around colons (pretty-printed style) + const lines = []; + for (let i = 0; i < 15; i++) { + lines.push('{ "type" : "user", "content": "message ' + i + '" }'); + } + fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.ok( - result.stderr.includes('15 messages'), - 'Should count user messages with whitespace in JSON, got: ' + result.stderr.trim() - ); + assert.ok(result.stderr.includes('15 messages'), 'Should count user messages with whitespace in JSON, got: ' + result.stderr.trim()); - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // session-end.js: content array with null elements regression test - if (await asyncTest('handles transcript with null content array elements (regression)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Create transcript with null elements in content array - const lines = [ - '{"type":"user","content":[null,{"text":"hello"},null,{"text":"world"}]}', - '{"type":"user","content":"simple string message"}', - '{"type":"user","content":[{"text":"normal"},{"text":"array"}]}', - '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/test.js"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - - // Should not crash (exit 0) - assert.strictEqual(result.code, 0, 'Should handle null content elements without crash'); - })) passed++; else failed++; + if ( + await asyncTest('handles transcript with null content array elements (regression)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // Create transcript with null elements in content array + const lines = [ + '{"type":"user","content":[null,{"text":"hello"},null,{"text":"world"}]}', + '{"type":"user","content":"simple string message"}', + '{"type":"user","content":[{"text":"normal"},{"text":"array"}]}', + '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/test.js"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + + // Should not crash (exit 0) + assert.strictEqual(result.code, 0, 'Should handle null content elements without crash'); + }) + ) + passed++; + else failed++; // post-edit-console-warn.js tests console.log('\npost-edit-console-warn.js:'); - if (await asyncTest('warns about console.log in JS files', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'test.js'); - fs.writeFileSync(testFile, 'const x = 1;\nconsole.log(x);\nreturn x;'); + if ( + await asyncTest('warns about console.log in JS files', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'test.js'); + fs.writeFileSync(testFile, 'const x = 1;\nconsole.log(x);\nreturn x;'); - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.ok(result.stderr.includes('console.log'), 'Should warn about console.log'); - cleanupTestDir(testDir); - })) passed++; else failed++; + assert.ok(result.stderr.includes('console.log'), 'Should warn about console.log'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - if (await asyncTest('does not warn for non-JS files', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'test.md'); - fs.writeFileSync(testFile, 'Use console.log for debugging'); + if ( + await asyncTest('does not warn for non-JS files', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'test.md'); + fs.writeFileSync(testFile, 'Use console.log for debugging'); - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.ok(!result.stderr.includes('console.log'), 'Should not warn for non-JS files'); - cleanupTestDir(testDir); - })) passed++; else failed++; + assert.ok(!result.stderr.includes('console.log'), 'Should not warn for non-JS files'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - if (await asyncTest('does not warn for clean JS files', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'clean.ts'); - fs.writeFileSync(testFile, 'const x = 1;\nreturn x;'); + if ( + await asyncTest('does not warn for clean JS files', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'clean.ts'); + fs.writeFileSync(testFile, 'const x = 1;\nreturn x;'); - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.ok(!result.stderr.includes('WARNING'), 'Should not warn for clean files'); - cleanupTestDir(testDir); - })) passed++; else failed++; + assert.ok(!result.stderr.includes('WARNING'), 'Should not warn for clean files'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles missing file gracefully', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + + assert.strictEqual(result.code, 0, 'Should not crash on missing file'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('limits console.log output to 5 matches', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'many-logs.js'); + // Create a file with 8 console.log statements + const lines = []; + for (let i = 1; i <= 8; i++) { + lines.push(`console.log('debug ${i}');`); + } + fs.writeFileSync(testFile, lines.join('\n')); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + + assert.ok(result.stderr.includes('console.log'), 'Should warn about console.log'); + // Count how many "debug N" lines appear in stderr (the line-number output) + const debugLines = result.stderr.split('\n').filter(l => /^\d+:/.test(l.trim())); + assert.ok(debugLines.length <= 5, `Should show at most 5 matches, got ${debugLines.length}`); + // Should include debug 1 but not debug 8 (sliced) + assert.ok(result.stderr.includes('debug 1'), 'Should include first match'); + assert.ok(!result.stderr.includes('debug 8'), 'Should not include 8th match'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - if (await asyncTest('handles missing file gracefully', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + if ( + await asyncTest('ignores console.warn and console.error (only flags console.log)', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'other-console.ts'); + fs.writeFileSync(testFile, ['console.warn("this is a warning");', 'console.error("this is an error");', 'console.debug("this is debug");', 'console.info("this is info");'].join('\n')); - assert.strictEqual(result.code, 0, 'Should not crash on missing file'); - })) passed++; else failed++; + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - if (await asyncTest('limits console.log output to 5 matches', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'many-logs.js'); - // Create a file with 8 console.log statements - const lines = []; - for (let i = 1; i <= 8; i++) { - lines.push(`console.log('debug ${i}');`); - } - fs.writeFileSync(testFile, lines.join('\n')); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - - assert.ok(result.stderr.includes('console.log'), 'Should warn about console.log'); - // Count how many "debug N" lines appear in stderr (the line-number output) - const debugLines = result.stderr.split('\n').filter(l => /^\d+:/.test(l.trim())); - assert.ok(debugLines.length <= 5, `Should show at most 5 matches, got ${debugLines.length}`); - // Should include debug 1 but not debug 8 (sliced) - assert.ok(result.stderr.includes('debug 1'), 'Should include first match'); - assert.ok(!result.stderr.includes('debug 8'), 'Should not include 8th match'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('ignores console.warn and console.error (only flags console.log)', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'other-console.ts'); - fs.writeFileSync(testFile, [ - 'console.warn("this is a warning");', - 'console.error("this is an error");', - 'console.debug("this is debug");', - 'console.info("this is info");', - ].join('\n')); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - - assert.ok(!result.stderr.includes('WARNING'), 'Should NOT warn about console.warn/error/debug/info'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('passes through original data on stdout', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - - assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); - })) passed++; else failed++; + assert.ok(!result.stderr.includes('WARNING'), 'Should NOT warn about console.warn/error/debug/info'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through original data on stdout', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + + assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); + }) + ) + passed++; + else failed++; // post-edit-format.js tests console.log('\npost-edit-format.js:'); - if (await asyncTest('runs without error on empty stdin', async () => { - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js')); - assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); - })) passed++; else failed++; - - if (await asyncTest('skips non-JS/TS files', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for non-JS files'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); - })) passed++; else failed++; - - if (await asyncTest('passes through data for invalid JSON', async () => { - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), 'not json'); - assert.strictEqual(result.code, 0, 'Should exit 0 for invalid JSON'); - })) passed++; else failed++; - - if (await asyncTest('handles null tool_input gracefully', async () => { - const stdinJson = JSON.stringify({ tool_input: null }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for null tool_input'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); - })) passed++; else failed++; - - if (await asyncTest('handles missing file_path in tool_input', async () => { - const stdinJson = JSON.stringify({ tool_input: {} }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for missing file_path'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); - })) passed++; else failed++; - - if (await asyncTest('exits 0 and passes data when prettier is unavailable', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/path/file.ts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 even when prettier fails'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through original data'); - })) passed++; else failed++; + if ( + await asyncTest('runs without error on empty stdin', async () => { + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js')); + assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('skips non-JS/TS files', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for non-JS files'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through data for invalid JSON', async () => { + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), 'not json'); + assert.strictEqual(result.code, 0, 'Should exit 0 for invalid JSON'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles null tool_input gracefully', async () => { + const stdinJson = JSON.stringify({ tool_input: null }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for null tool_input'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles missing file_path in tool_input', async () => { + const stdinJson = JSON.stringify({ tool_input: {} }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for missing file_path'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exits 0 and passes data when prettier is unavailable', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/path/file.ts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 even when prettier fails'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through original data'); + }) + ) + passed++; + else failed++; // post-edit-typecheck.js tests console.log('\npost-edit-typecheck.js:'); - if (await asyncTest('runs without error on empty stdin', async () => { - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js')); - assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); - })) passed++; else failed++; - - if (await asyncTest('skips non-TypeScript files', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.js' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for non-TS files'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); - })) passed++; else failed++; - - if (await asyncTest('handles nonexistent TS file gracefully', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for missing file'); - })) passed++; else failed++; - - if (await asyncTest('handles TS file with no tsconfig gracefully', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'test.ts'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 when no tsconfig found'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('stops tsconfig walk at max depth (20)', async () => { - // Create a deeply nested directory (>20 levels) with no tsconfig anywhere - const testDir = createTestDir(); - let deepDir = testDir; - for (let i = 0; i < 25; i++) { - deepDir = path.join(deepDir, `d${i}`); - } - fs.mkdirSync(deepDir, { recursive: true }); - const testFile = path.join(deepDir, 'deep.ts'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const startTime = Date.now(); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - const elapsed = Date.now() - startTime; - - assert.strictEqual(result.code, 0, 'Should not hang at depth limit'); - assert.ok(elapsed < 5000, `Should complete quickly at depth limit, took ${elapsed}ms`); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('passes through stdin data on stdout (post-edit-typecheck)', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'test.ts'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data on stdout'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('runs without error on empty stdin', async () => { + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js')); + assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('skips non-TypeScript files', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.js' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for non-TS files'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles nonexistent TS file gracefully', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for missing file'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles TS file with no tsconfig gracefully', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'test.ts'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 when no tsconfig found'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('stops tsconfig walk at max depth (20)', async () => { + // Create a deeply nested directory (>20 levels) with no tsconfig anywhere + const testDir = createTestDir(); + let deepDir = testDir; + for (let i = 0; i < 25; i++) { + deepDir = path.join(deepDir, `d${i}`); + } + fs.mkdirSync(deepDir, { recursive: true }); + const testFile = path.join(deepDir, 'deep.ts'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const startTime = Date.now(); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + const elapsed = Date.now() - startTime; + + assert.strictEqual(result.code, 0, 'Should not hang at depth limit'); + assert.ok(elapsed < 5000, `Should complete quickly at depth limit, took ${elapsed}ms`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through stdin data on stdout (post-edit-typecheck)', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'test.ts'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data on stdout'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // session-end.js extractSessionSummary tests console.log('\nsession-end.js (extractSessionSummary):'); - if (await asyncTest('extracts user messages from transcript', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Fix the login bug"}', - '{"type":"assistant","content":"I will fix it"}', - '{"type":"user","content":"Also add tests"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles transcript with array content fields', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":[{"text":"Part 1"},{"text":"Part 2"}]}', - '{"type":"user","content":"Simple message"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle array content without crash'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('extracts tool names and file paths from transcript', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Edit the file"}', - '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/main.ts"}}', - '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', - '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0); - // Session file should contain summary with tools used - assert.ok( - result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), - 'Should create/update session file' - ); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles transcript with malformed JSON lines', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Valid message"}', - 'NOT VALID JSON', - '{"broken json', - '{"type":"user","content":"Another valid"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should skip malformed lines gracefully'); - assert.ok( - result.stderr.includes('unparseable') || result.stderr.includes('Skipped'), - `Should report parse errors, got: ${result.stderr.substring(0, 200)}` - ); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles empty transcript (no user messages)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Only tool_use entries, no user messages - const lines = [ - '{"type":"tool_use","tool_name":"Read","tool_input":{}}', - '{"type":"assistant","content":"done"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle transcript with no user messages'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('truncates long user messages to 200 chars', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const longMsg = 'x'.repeat(500); - const lines = [ - `{"type":"user","content":"${longMsg}"}`, - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle and truncate long messages'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('uses CLAUDE_TRANSCRIPT_PATH env var as fallback', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Fallback test message"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - // Send invalid JSON to stdin so it falls back to env var - const result = await runScript(path.join(scriptsDir, 'session-end.js'), 'not json', { - CLAUDE_TRANSCRIPT_PATH: transcriptPath - }); - assert.strictEqual(result.code, 0, 'Should use env var fallback'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('escapes backticks in user messages in session file', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // User messages with backticks that could break markdown - const lines = [ - '{"type":"user","content":"Fix the `handleAuth` function in `auth.ts`"}', - '{"type":"user","content":"Run `npm test` to verify"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0, 'Should handle backticks without crash'); - - // Find the session file in the temp HOME - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Backticks should be escaped in the output - assert.ok(content.includes('\\`'), 'Should escape backticks in session file'); - assert.ok(!content.includes('`handleAuth`'), 'Raw backticks should be escaped'); - } - } - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('session file contains tools used and files modified', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Edit the config"}', - '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/config.ts"}}', - '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', - '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new-file.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Should contain files modified (Edit and Write, not Read) - assert.ok(content.includes('/src/config.ts'), 'Should list edited file'); - assert.ok(content.includes('/src/new-file.ts'), 'Should list written file'); - // Should contain tools used - assert.ok(content.includes('Edit'), 'Should list Edit tool'); - assert.ok(content.includes('Read'), 'Should list Read tool'); - } - } - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('omits Tools Used and Files Modified sections when empty', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Only user messages, no tool_use entries - const lines = [ - '{"type":"user","content":"Just chatting"}', - '{"type":"user","content":"No tools used at all"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('### Tasks'), 'Should have Tasks section'); - assert.ok(!content.includes('### Files Modified'), 'Should NOT have Files Modified when empty'); - assert.ok(!content.includes('### Tools Used'), 'Should NOT have Tools Used when empty'); - assert.ok(content.includes('Total user messages: 2'), 'Should show correct message count'); - } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('extracts user messages from transcript', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); - if (await asyncTest('slices user messages to last 10', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const lines = ['{"type":"user","content":"Fix the login bug"}', '{"type":"assistant","content":"I will fix it"}', '{"type":"user","content":"Also add tests"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); - // 15 user messages — should keep only last 10 - const lines = []; - for (let i = 1; i <= 15; i++) { - lines.push(`{"type":"user","content":"UserMsg_${i}"}`); - } - fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles transcript with array content fields', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = ['{"type":"user","content":[{"text":"Part 1"},{"text":"Part 2"}]}', '{"type":"user","content":"Simple message"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle array content without crash'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('extracts tool names and file paths from transcript', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = [ + '{"type":"user","content":"Edit the file"}', + '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/main.ts"}}', + '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', + '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new.ts"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0); + // Session file should contain summary with tools used + assert.ok(result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), 'Should create/update session file'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles transcript with malformed JSON lines', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = ['{"type":"user","content":"Valid message"}', 'NOT VALID JSON', '{"broken json', '{"type":"user","content":"Another valid"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should skip malformed lines gracefully'); + assert.ok(result.stderr.includes('unparseable') || result.stderr.includes('Skipped'), `Should report parse errors, got: ${result.stderr.substring(0, 200)}`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles empty transcript (no user messages)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // Only tool_use entries, no user messages + const lines = ['{"type":"tool_use","tool_name":"Read","tool_input":{}}', '{"type":"assistant","content":"done"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle transcript with no user messages'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('truncates long user messages to 200 chars', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const longMsg = 'x'.repeat(500); + const lines = [`{"type":"user","content":"${longMsg}"}`]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle and truncate long messages'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Should NOT contain first 5 messages (sliced to last 10) - assert.ok(!content.includes('UserMsg_1\n'), 'Should not include first message (sliced)'); - assert.ok(!content.includes('UserMsg_5\n'), 'Should not include 5th message (sliced)'); - // Should contain messages 6-15 - assert.ok(content.includes('UserMsg_6'), 'Should include 6th message'); - assert.ok(content.includes('UserMsg_15'), 'Should include last message'); - assert.ok(content.includes('Total user messages: 15'), 'Should show total of 15'); + if ( + await asyncTest('uses CLAUDE_TRANSCRIPT_PATH env var as fallback', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = ['{"type":"user","content":"Fallback test message"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + // Send invalid JSON to stdin so it falls back to env var + const result = await runScript(path.join(scriptsDir, 'session-end.js'), 'not json', { + CLAUDE_TRANSCRIPT_PATH: transcriptPath + }); + assert.strictEqual(result.code, 0, 'Should use env var fallback'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('escapes backticks in user messages in session file', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // User messages with backticks that could break markdown + const lines = ['{"type":"user","content":"Fix the `handleAuth` function in `auth.ts`"}', '{"type":"user","content":"Run `npm test` to verify"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0, 'Should handle backticks without crash'); + + // Find the session file in the temp HOME + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Backticks should be escaped in the output + assert.ok(content.includes('\\`'), 'Should escape backticks in session file'); + assert.ok(!content.includes('`handleAuth`'), 'Raw backticks should be escaped'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('session file contains tools used and files modified', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = [ + '{"type":"user","content":"Edit the config"}', + '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/config.ts"}}', + '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', + '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new-file.ts"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); - if (await asyncTest('slices tools to first 20', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Should contain files modified (Edit and Write, not Read) + assert.ok(content.includes('/src/config.ts'), 'Should list edited file'); + assert.ok(content.includes('/src/new-file.ts'), 'Should list written file'); + // Should contain tools used + assert.ok(content.includes('Edit'), 'Should list Edit tool'); + assert.ok(content.includes('Read'), 'Should list Read tool'); + } + } + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - // 25 unique tools — should keep only first 20 - const lines = ['{"type":"user","content":"Do stuff"}']; - for (let i = 1; i <= 25; i++) { - lines.push(`{"type":"tool_use","tool_name":"Tool${i}","tool_input":{}}`); - } - fs.writeFileSync(transcriptPath, lines.join('\n')); + if ( + await asyncTest('omits Tools Used and Files Modified sections when empty', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Should contain Tool1 through Tool20 - assert.ok(content.includes('Tool1'), 'Should include Tool1'); - assert.ok(content.includes('Tool20'), 'Should include Tool20'); - // Should NOT contain Tool21-25 (sliced) - assert.ok(!content.includes('Tool21'), 'Should not include Tool21 (sliced to 20)'); - assert.ok(!content.includes('Tool25'), 'Should not include Tool25 (sliced to 20)'); + // Only user messages, no tool_use entries + const lines = ['{"type":"user","content":"Just chatting"}', '{"type":"user","content":"No tools used at all"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); + + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('### Tasks'), 'Should have Tasks section'); + assert.ok(!content.includes('### Files Modified'), 'Should NOT have Files Modified when empty'); + assert.ok(!content.includes('### Tools Used'), 'Should NOT have Tools Used when empty'); + assert.ok(content.includes('Total user messages: 2'), 'Should show correct message count'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('slices user messages to last 10', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // 15 user messages — should keep only last 10 + const lines = []; + for (let i = 1; i <= 15; i++) { + lines.push(`{"type":"user","content":"UserMsg_${i}"}`); + } + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); - if (await asyncTest('slices files modified to first 30', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Should NOT contain first 5 messages (sliced to last 10) + assert.ok(!content.includes('UserMsg_1\n'), 'Should not include first message (sliced)'); + assert.ok(!content.includes('UserMsg_5\n'), 'Should not include 5th message (sliced)'); + // Should contain messages 6-15 + assert.ok(content.includes('UserMsg_6'), 'Should include 6th message'); + assert.ok(content.includes('UserMsg_15'), 'Should include last message'); + assert.ok(content.includes('Total user messages: 15'), 'Should show total of 15'); + } + } + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('slices tools to first 20', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // 25 unique tools — should keep only first 20 + const lines = ['{"type":"user","content":"Do stuff"}']; + for (let i = 1; i <= 25; i++) { + lines.push(`{"type":"tool_use","tool_name":"Tool${i}","tool_input":{}}`); + } + fs.writeFileSync(transcriptPath, lines.join('\n')); - // 35 unique files via Edit — should keep only first 30 - const lines = ['{"type":"user","content":"Edit all the things"}']; - for (let i = 1; i <= 35; i++) { - lines.push(`{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/file${i}.ts"}}`); - } - fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Should contain file1 through file30 - assert.ok(content.includes('/src/file1.ts'), 'Should include file1'); - assert.ok(content.includes('/src/file30.ts'), 'Should include file30'); - // Should NOT contain file31-35 (sliced) - assert.ok(!content.includes('/src/file31.ts'), 'Should not include file31 (sliced to 30)'); - assert.ok(!content.includes('/src/file35.ts'), 'Should not include file35 (sliced to 30)'); + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Should contain Tool1 through Tool20 + assert.ok(content.includes('Tool1'), 'Should include Tool1'); + assert.ok(content.includes('Tool20'), 'Should include Tool20'); + // Should NOT contain Tool21-25 (sliced) + assert.ok(!content.includes('Tool21'), 'Should not include Tool21 (sliced to 20)'); + assert.ok(!content.includes('Tool25'), 'Should not include Tool25 (sliced to 20)'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('parses Claude Code JSONL format (entry.message.content)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Claude Code v2.1.41+ JSONL format: user messages nested in entry.message - const lines = [ - '{"type":"user","message":{"role":"user","content":"Fix the build error"}}', - '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Also update tests"}]}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('slices files modified to first 30', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // 35 unique files via Edit — should keep only first 30 + const lines = ['{"type":"user","content":"Edit all the things"}']; + for (let i = 1; i <= 35; i++) { + lines.push(`{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/file${i}.ts"}}`); + } + fs.writeFileSync(transcriptPath, lines.join('\n')); - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('Fix the build error'), 'Should extract string content from message'); - assert.ok(content.includes('Also update tests'), 'Should extract array content from message'); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); + + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Should contain file1 through file30 + assert.ok(content.includes('/src/file1.ts'), 'Should include file1'); + assert.ok(content.includes('/src/file30.ts'), 'Should include file30'); + // Should NOT contain file31-35 (sliced) + assert.ok(!content.includes('/src/file31.ts'), 'Should not include file31 (sliced to 30)'); + assert.ok(!content.includes('/src/file35.ts'), 'Should not include file35 (sliced to 30)'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('extracts tool_use from assistant message content blocks', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - // Claude Code JSONL: tool uses nested in assistant message content array - const lines = [ - '{"type":"user","content":"Edit the config"}', - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'I will edit the file.' }, - { type: 'tool_use', name: 'Edit', input: { file_path: '/src/app.ts' } }, - { type: 'tool_use', name: 'Write', input: { file_path: '/src/new.ts' } }, - ] + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('parses Claude Code JSONL format (entry.message.content)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // Claude Code v2.1.41+ JSONL format: user messages nested in entry.message + const lines = ['{"type":"user","message":{"role":"user","content":"Fix the build error"}}', '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Also update tests"}]}}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); + + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('Fix the build error'), 'Should extract string content from message'); + assert.ok(content.includes('Also update tests'), 'Should extract array content from message'); } - }), - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); + } + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('extracts tool_use from assistant message content blocks', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // Claude Code JSONL: tool uses nested in assistant message content array + const lines = [ + '{"type":"user","content":"Edit the config"}', + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'I will edit the file.' }, + { type: 'tool_use', name: 'Edit', input: { file_path: '/src/app.ts' } }, + { type: 'tool_use', name: 'Write', input: { file_path: '/src/new.ts' } } + ] + } + }) + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('Edit'), 'Should extract Edit tool from content blocks'); - assert.ok(content.includes('/src/app.ts'), 'Should extract file path from Edit block'); - assert.ok(content.includes('/src/new.ts'), 'Should extract file path from Write block'); + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('Edit'), 'Should extract Edit tool from content blocks'); + assert.ok(content.includes('/src/app.ts'), 'Should extract file path from Edit block'); + assert.ok(content.includes('/src/new.ts'), 'Should extract file path from Write block'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // hooks.json validation console.log('\nhooks.json Validation:'); - if (test('hooks.json is valid JSON', () => { - const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); - const content = fs.readFileSync(hooksPath, 'utf8'); - JSON.parse(content); // Will throw if invalid - })) passed++; else failed++; - - if (test('hooks.json has required event types', () => { - const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); - const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); - - assert.ok(hooks.hooks.PreToolUse, 'Should have PreToolUse hooks'); - assert.ok(hooks.hooks.PostToolUse, 'Should have PostToolUse hooks'); - assert.ok(hooks.hooks.SessionStart, 'Should have SessionStart hooks'); - assert.ok(hooks.hooks.SessionEnd, 'Should have SessionEnd hooks'); - assert.ok(hooks.hooks.Stop, 'Should have Stop hooks'); - assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks'); - })) passed++; else failed++; - - if (test('all hook commands use node or approved shell wrappers', () => { - const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); - const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); - - const checkHooks = (hookArray) => { - for (const entry of hookArray) { - for (const hook of entry.hooks) { - if (hook.type === 'command') { - const isNode = hook.command.startsWith('node'); - const isSkillScript = hook.command.includes('/skills/') && ( - /^(bash|sh)\s/.test(hook.command) || - hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/') - ); - const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command); - const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); - assert.ok( - isNode || isSkillScript || isHookShellWrapper || isSessionStartFallback, - `Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...` - ); + if ( + test('hooks.json is valid JSON', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const content = fs.readFileSync(hooksPath, 'utf8'); + JSON.parse(content); // Will throw if invalid + }) + ) + passed++; + else failed++; + + if ( + test('hooks.json has required event types', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + + assert.ok(hooks.hooks.PreToolUse, 'Should have PreToolUse hooks'); + assert.ok(hooks.hooks.PostToolUse, 'Should have PostToolUse hooks'); + assert.ok(hooks.hooks.SessionStart, 'Should have SessionStart hooks'); + assert.ok(hooks.hooks.SessionEnd, 'Should have SessionEnd hooks'); + assert.ok(hooks.hooks.Stop, 'Should have Stop hooks'); + assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks'); + }) + ) + passed++; + else failed++; + + if ( + test('all hook commands use node or approved shell wrappers', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + + const checkHooks = hookArray => { + for (const entry of hookArray) { + for (const hook of entry.hooks) { + if (hook.type === 'command') { + const isNode = hook.command.startsWith('node'); + const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')); + const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command); + const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); + assert.ok(isNode || isSkillScript || isHookShellWrapper || isSessionStartFallback, `Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`); + } } } - } - }; + }; - for (const [, hookArray] of Object.entries(hooks.hooks)) { - checkHooks(hookArray); - } - })) passed++; else failed++; - - if (test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => { - const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); - const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); - - const checkHooks = (hookArray) => { - for (const entry of hookArray) { - for (const hook of entry.hooks) { - if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) { - // Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command - const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); - const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback; - assert.ok( - hasPluginRoot, - `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...` - ); + for (const [, hookArray] of Object.entries(hooks.hooks)) { + checkHooks(hookArray); + } + }) + ) + passed++; + else failed++; + + if ( + test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + + const checkHooks = hookArray => { + for (const entry of hookArray) { + for (const hook of entry.hooks) { + if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) { + // Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command + const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); + const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback; + assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`); + } } } - } - }; + }; - for (const [, hookArray] of Object.entries(hooks.hooks)) { - checkHooks(hookArray); - } - })) passed++; else failed++; + for (const [, hookArray] of Object.entries(hooks.hooks)) { + checkHooks(hookArray); + } + }) + ) + passed++; + else failed++; // plugin.json validation console.log('\nplugin.json Validation:'); - if (test('plugin.json does NOT have explicit hooks declaration', () => { - // Claude Code automatically loads hooks/hooks.json by convention. - // Explicitly declaring it in plugin.json causes a duplicate detection error. - // See: https://github.com/affaan-m/everything-claude-code/issues/103 - const pluginPath = path.join(__dirname, '..', '..', '.claude-plugin', 'plugin.json'); - const plugin = JSON.parse(fs.readFileSync(pluginPath, 'utf8')); + if ( + test('plugin.json does NOT have explicit hooks declaration', () => { + // Claude Code automatically loads hooks/hooks.json by convention. + // Explicitly declaring it in plugin.json causes a duplicate detection error. + // See: https://github.com/affaan-m/everything-claude-code/issues/103 + const pluginPath = path.join(__dirname, '..', '..', '.claude-plugin', 'plugin.json'); + const plugin = JSON.parse(fs.readFileSync(pluginPath, 'utf8')); - assert.ok( - !plugin.hooks, - 'plugin.json should NOT have "hooks" field - Claude Code auto-loads hooks/hooks.json' - ); - })) passed++; else failed++; + assert.ok(!plugin.hooks, 'plugin.json should NOT have "hooks" field - Claude Code auto-loads hooks/hooks.json'); + }) + ) + passed++; + else failed++; // ─── evaluate-session.js tests ─── console.log('\nevaluate-session.js:'); - if (await asyncTest('skips when no transcript_path in stdin', async () => { - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), '{}'); - assert.strictEqual(result.code, 0, 'Should exit 0 (non-blocking)'); - })) passed++; else failed++; - - if (await asyncTest('skips when transcript file does not exist', async () => { - const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-12345.jsonl' }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 when file missing'); - })) passed++; else failed++; - - if (await asyncTest('skips short sessions (< 10 user messages)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'short.jsonl'); - // Only 3 user messages — below the default threshold of 10 - const lines = [ - '{"type":"user","content":"msg1"}', - '{"type":"user","content":"msg2"}', - '{"type":"user","content":"msg3"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('too short'), 'Should log "too short" message'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('evaluates long sessions (>= 10 user messages)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'long.jsonl'); - // 12 user messages — above the default threshold - const lines = []; - for (let i = 0; i < 12; i++) { - lines.push(`{"type":"user","content":"message ${i}"}`); - } - fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('12 messages'), 'Should report message count'); - assert.ok(result.stderr.includes('evaluate'), 'Should signal evaluation'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles malformed stdin JSON (falls back to env var)', async () => { - const result = await runScript( - path.join(scriptsDir, 'evaluate-session.js'), - 'not json at all', - { CLAUDE_TRANSCRIPT_PATH: '' } - ); - // No valid transcript path from either source → exit 0 - assert.strictEqual(result.code, 0); - })) passed++; else failed++; - - // ─── suggest-compact.js tests ─── - console.log('\nsuggest-compact.js:'); - - if (await asyncTest('increments tool counter on each invocation', async () => { - const sessionId = `test-counter-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // First invocation → count = 1 - await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - let val = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(val, 1, 'First call should write count 1'); - - // Second invocation → count = 2 - await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - val = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(val, 2, 'Second call should write count 2'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('suggests compact at exact threshold', async () => { - const sessionId = `test-threshold-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Pre-seed counter at threshold - 1 so next call hits threshold - fs.writeFileSync(counterFile, '4'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '5' - }); + if ( + await asyncTest('skips when no transcript_path in stdin', async () => { + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), '{}'); + assert.strictEqual(result.code, 0, 'Should exit 0 (non-blocking)'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('skips when transcript file does not exist', async () => { + const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-12345.jsonl' }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 when file missing'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('skips short sessions (< 10 user messages)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'short.jsonl'); + // Only 3 user messages — below the default threshold of 10 + const lines = ['{"type":"user","content":"msg1"}', '{"type":"user","content":"msg2"}', '{"type":"user","content":"msg3"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('5 tool calls reached'), 'Should suggest compact at threshold'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('suggests at periodic intervals after threshold', async () => { - const sessionId = `test-periodic-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Pre-seed at 29 so next call = 30 (threshold 5 + 25 = 30) - // (30 - 5) % 25 === 0 → should trigger periodic suggestion - fs.writeFileSync(counterFile, '29'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '5' - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('30 tool calls'), 'Should suggest at threshold + 25n intervals'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('does not suggest below threshold', async () => { - const sessionId = `test-below-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - fs.writeFileSync(counterFile, '2'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '50' - }); + assert.ok(result.stderr.includes('too short'), 'Should log "too short" message'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('evaluates long sessions (>= 10 user messages)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'long.jsonl'); + // 12 user messages — above the default threshold + const lines = []; + for (let i = 0; i < 12; i++) { + lines.push(`{"type":"user","content":"message ${i}"}`); + } + fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); assert.strictEqual(result.code, 0); - assert.ok(!result.stderr.includes('tool calls reached'), 'Should not suggest below threshold'); - assert.ok(!result.stderr.includes('checkpoint'), 'Should not suggest checkpoint'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('resets counter when file contains huge overflow number', async () => { - const sessionId = `test-overflow-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Write a value that passes Number.isFinite() but exceeds 1000000 clamp - fs.writeFileSync(counterFile, '999999999999'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); + assert.ok(result.stderr.includes('12 messages'), 'Should report message count'); + assert.ok(result.stderr.includes('evaluate'), 'Should signal evaluation'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles malformed stdin JSON (falls back to env var)', async () => { + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), 'not json at all', { CLAUDE_TRANSCRIPT_PATH: '' }); + // No valid transcript path from either source → exit 0 assert.strictEqual(result.code, 0); - // Should reset to 1 because 999999999999 > 1000000 - const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(newCount, 1, 'Should reset to 1 on overflow value'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; + }) + ) + passed++; + else failed++; - if (await asyncTest('resets counter when file contains negative number', async () => { - const sessionId = `test-negative-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - fs.writeFileSync(counterFile, '-42'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - assert.strictEqual(result.code, 0); - const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(newCount, 1, 'Should reset to 1 on negative value'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; + // ─── suggest-compact.js tests ─── + console.log('\nsuggest-compact.js:'); - if (await asyncTest('handles COMPACT_THRESHOLD of zero (falls back to 50)', async () => { - const sessionId = `test-zero-thresh-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - fs.writeFileSync(counterFile, '49'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '0' - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('50 tool calls reached'), 'Zero threshold should fall back to 50'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; + if ( + await asyncTest('increments tool counter on each invocation', async () => { + const sessionId = `test-counter-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // First invocation → count = 1 + await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + let val = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(val, 1, 'First call should write count 1'); - if (await asyncTest('handles invalid COMPACT_THRESHOLD (falls back to 50)', async () => { - const sessionId = `test-invalid-thresh-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Pre-seed at 49 so next call = 50 (the fallback default) - fs.writeFileSync(counterFile, '49'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: 'not-a-number' - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('50 tool calls reached'), 'Should use default threshold of 50'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; + // Second invocation → count = 2 + await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + val = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(val, 2, 'Second call should write count 2'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('suggests compact at exact threshold', async () => { + const sessionId = `test-threshold-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Pre-seed counter at threshold - 1 so next call hits threshold + fs.writeFileSync(counterFile, '4'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '5' + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('5 tool calls reached'), 'Should suggest compact at threshold'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('suggests at periodic intervals after threshold', async () => { + const sessionId = `test-periodic-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Pre-seed at 29 so next call = 30 (threshold 5 + 25 = 30) + // (30 - 5) % 25 === 0 → should trigger periodic suggestion + fs.writeFileSync(counterFile, '29'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '5' + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('30 tool calls'), 'Should suggest at threshold + 25n intervals'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does not suggest below threshold', async () => { + const sessionId = `test-below-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + fs.writeFileSync(counterFile, '2'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '50' + }); + assert.strictEqual(result.code, 0); + assert.ok(!result.stderr.includes('tool calls reached'), 'Should not suggest below threshold'); + assert.ok(!result.stderr.includes('checkpoint'), 'Should not suggest checkpoint'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('resets counter when file contains huge overflow number', async () => { + const sessionId = `test-overflow-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Write a value that passes Number.isFinite() but exceeds 1000000 clamp + fs.writeFileSync(counterFile, '999999999999'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + assert.strictEqual(result.code, 0); + // Should reset to 1 because 999999999999 > 1000000 + const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(newCount, 1, 'Should reset to 1 on overflow value'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('resets counter when file contains negative number', async () => { + const sessionId = `test-negative-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + fs.writeFileSync(counterFile, '-42'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + assert.strictEqual(result.code, 0); + const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(newCount, 1, 'Should reset to 1 on negative value'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles COMPACT_THRESHOLD of zero (falls back to 50)', async () => { + const sessionId = `test-zero-thresh-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + fs.writeFileSync(counterFile, '49'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '0' + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('50 tool calls reached'), 'Zero threshold should fall back to 50'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles invalid COMPACT_THRESHOLD (falls back to 50)', async () => { + const sessionId = `test-invalid-thresh-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Pre-seed at 49 so next call = 50 (the fallback default) + fs.writeFileSync(counterFile, '49'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: 'not-a-number' + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('50 tool calls reached'), 'Should use default threshold of 50'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; // ─── Round 20 bug fix tests ─── console.log('\ncheck-console-log.js (exact pass-through):'); - if (await asyncTest('stdout is exact byte match of stdin (no trailing newline)', async () => { - // Before the fix, console.log(data) added a trailing \n. - // process.stdout.write(data) should preserve exact bytes. - const stdinData = '{"tool":"test","value":42}'; - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); - assert.strictEqual(result.code, 0); - // stdout should be exactly the input — no extra newline appended - assert.strictEqual(result.stdout, stdinData, 'Should not append extra newline to output'); - })) passed++; else failed++; - - if (await asyncTest('preserves empty string stdin without adding newline', async () => { - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, '', 'Empty input should produce empty output'); - })) passed++; else failed++; - - if (await asyncTest('preserves data with embedded newlines exactly', async () => { - const stdinData = 'line1\nline2\nline3'; - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinData, 'Should preserve embedded newlines without adding extra'); - })) passed++; else failed++; + if ( + await asyncTest('stdout is exact byte match of stdin (no trailing newline)', async () => { + // Before the fix, console.log(data) added a trailing \n. + // process.stdout.write(data) should preserve exact bytes. + const stdinData = '{"tool":"test","value":42}'; + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); + assert.strictEqual(result.code, 0); + // stdout should be exactly the input — no extra newline appended + assert.strictEqual(result.stdout, stdinData, 'Should not append extra newline to output'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('preserves empty string stdin without adding newline', async () => { + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, '', 'Empty input should produce empty output'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('preserves data with embedded newlines exactly', async () => { + const stdinData = 'line1\nline2\nline3'; + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinData, 'Should preserve embedded newlines without adding extra'); + }) + ) + passed++; + else failed++; console.log('\npost-edit-format.js (security & extension tests):'); - if (await asyncTest('source code does not pass shell option to execFileSync (security)', async () => { - const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - // Strip comments to avoid matching "shell: true" in comment text - const codeOnly = formatSource.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); - assert.ok(!codeOnly.includes('shell:'), 'post-edit-format.js should not pass shell option in code'); - assert.ok(formatSource.includes('npx.cmd'), 'Should use npx.cmd for Windows cross-platform safety'); - })) passed++; else failed++; - - if (await asyncTest('matches .tsx extension for formatting', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.tsx' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0); - // Should attempt to format (will fail silently since file doesn't exist, but should pass through) - assert.ok(result.stdout.includes('component.tsx'), 'Should pass through data for .tsx files'); - })) passed++; else failed++; - - if (await asyncTest('matches .jsx extension for formatting', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.jsx' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('component.jsx'), 'Should pass through data for .jsx files'); - })) passed++; else failed++; + if ( + await asyncTest('source code does not pass shell option to execFileSync (security)', async () => { + const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); + // Strip comments to avoid matching "shell: true" in comment text + const codeOnly = formatSource.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + assert.ok(!codeOnly.includes('shell:'), 'post-edit-format.js should not pass shell option in code'); + // npx.cmd handling moved to shared resolve-formatter.js — verify it uses the shared module + const resolverSource = fs.readFileSync(path.join(scriptsDir, '..', 'lib', 'resolve-formatter.js'), 'utf8'); + assert.ok(resolverSource.includes('npx.cmd'), 'resolve-formatter.js should use npx.cmd for Windows cross-platform safety'); + assert.ok(formatSource.includes('resolveFormatterBin'), 'post-edit-format.js should use shared resolveFormatterBin'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('matches .tsx extension for formatting', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.tsx' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0); + // Should attempt to format (will fail silently since file doesn't exist, but should pass through) + assert.ok(result.stdout.includes('component.tsx'), 'Should pass through data for .tsx files'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('matches .jsx extension for formatting', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.jsx' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('component.jsx'), 'Should pass through data for .jsx files'); + }) + ) + passed++; + else failed++; console.log('\npost-edit-typecheck.js (security & extension tests):'); - if (await asyncTest('source code does not pass shell option to execFileSync (security)', async () => { - const typecheckSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); - // Strip comments to avoid matching "shell: true" in comment text - const codeOnly = typecheckSource.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); - assert.ok(!codeOnly.includes('shell:'), 'post-edit-typecheck.js should not pass shell option in code'); - assert.ok(typecheckSource.includes('npx.cmd'), 'Should use npx.cmd for Windows cross-platform safety'); - })) passed++; else failed++; - - if (await asyncTest('matches .tsx extension for type checking', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'component.tsx'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data for .tsx files'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('source code does not pass shell option to execFileSync (security)', async () => { + const typecheckSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); + // Strip comments to avoid matching "shell: true" in comment text + const codeOnly = typecheckSource.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + assert.ok(!codeOnly.includes('shell:'), 'post-edit-typecheck.js should not pass shell option in code'); + assert.ok(typecheckSource.includes('npx.cmd'), 'Should use npx.cmd for Windows cross-platform safety'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('matches .tsx extension for type checking', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'component.tsx'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data for .tsx files'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ─── Round 23: Bug fixes & high-priority gap coverage ─── @@ -1537,1879 +1837,2242 @@ async function runTests() { const wrapperScript = path.join(testDir, 'eval-wrapper.js'); let src = fs.readFileSync(path.join(scriptsDir, 'evaluate-session.js'), 'utf8'); // Patch require to use absolute path (the temp dir doesn't have ../lib/utils) - src = src.replace( - /require\('\.\.\/lib\/utils'\)/, - `require(${JSON.stringify(realUtilsPath)})` - ); + src = src.replace(/require\('\.\.\/lib\/utils'\)/, `require(${JSON.stringify(realUtilsPath)})`); // Patch config file path to point to our test config - src = src.replace( - /const configFile = path\.join\(scriptDir.*?config\.json'\);/, - `const configFile = ${JSON.stringify(configPath)};` - ); + src = src.replace(/const configFile = path\.join\(scriptDir.*?config\.json'\);/, `const configFile = ${JSON.stringify(configPath)};`); fs.writeFileSync(wrapperScript, src); return wrapperScript; } console.log('\nRound 23: evaluate-session.js (config & nullish coalescing):'); - if (await asyncTest('respects min_session_length=0 from config (nullish coalescing)', async () => { - // This tests the ?? fix: min_session_length=0 should mean "evaluate ALL sessions" - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'short.jsonl'); - // Only 2 user messages — normally below the default threshold of 10 - const lines = [ - '{"type":"user","content":"msg1"}', - '{"type":"user","content":"msg2"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - // Create a config file with min_session_length=0 - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - min_session_length: 0, - learned_skills_path: path.join(testDir, 'learned') - })); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // With min_session_length=0, even 2 messages should trigger evaluation - assert.ok( - result.stderr.includes('2 messages') && result.stderr.includes('evaluate'), - 'Should evaluate session with min_session_length=0 (not skip as too short)' - ); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('config with min_session_length=null falls back to default 10', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'short.jsonl'); - // 5 messages — below default 10 - const lines = []; - for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - min_session_length: null, - learned_skills_path: path.join(testDir, 'learned') - })); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // null ?? 10 === 10, so 5 messages should be "too short" - assert.ok(result.stderr.includes('too short'), 'Should fall back to default 10 when null'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('config with custom learned_skills_path creates directory', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); - - const customLearnedDir = path.join(testDir, 'custom-learned-skills'); - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - fs.writeFileSync(configPath, JSON.stringify({ - learned_skills_path: customLearnedDir - })); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.ok(fs.existsSync(customLearnedDir), 'Should create custom learned skills directory'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles invalid config JSON gracefully (uses defaults)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - const lines = []; - for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - fs.writeFileSync(configPath, 'not valid json!!!'); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // Should log parse failure and fall back to default 10 → 5 msgs too short - assert.ok(result.stderr.includes('too short'), 'Should use defaults when config is invalid JSON'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('respects min_session_length=0 from config (nullish coalescing)', async () => { + // This tests the ?? fix: min_session_length=0 should mean "evaluate ALL sessions" + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'short.jsonl'); + // Only 2 user messages — normally below the default threshold of 10 + const lines = ['{"type":"user","content":"msg1"}', '{"type":"user","content":"msg2"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + // Create a config file with min_session_length=0 + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + min_session_length: 0, + learned_skills_path: path.join(testDir, 'learned') + }) + ); - console.log('\nRound 23: session-end.js (update existing file path):'); + const wrapperScript = createEvalWrapper(testDir, configPath); - if (await asyncTest('updates Last Updated timestamp in existing session file', async () => { - const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.strictEqual(result.code, 0); + // With min_session_length=0, even 2 messages should trigger evaluation + assert.ok(result.stderr.includes('2 messages') && result.stderr.includes('evaluate'), 'Should evaluate session with min_session_length=0 (not skip as too short)'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('config with min_session_length=null falls back to default 10', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'short.jsonl'); + // 5 messages — below default 10 + const lines = []; + for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + min_session_length: null, + learned_skills_path: path.join(testDir, 'learned') + }) + ); - // Get the expected filename - const utils = require('../../scripts/lib/utils'); - const today = utils.getDateString(); + const wrapperScript = createEvalWrapper(testDir, configPath); - // Create a pre-existing session file with known timestamp - const shortId = 'update01'; - const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - const originalContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 09:00\n**Last Updated:** 09:00\n\n---\n\n## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`; - fs.writeFileSync(sessionFile, originalContent); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.strictEqual(result.code, 0); + // null ?? 10 === 10, so 5 messages should be "too short" + assert.ok(result.stderr.includes('too short'), 'Should fall back to default 10 when null'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('config with custom learned_skills_path creates directory', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); + + const customLearnedDir = path.join(testDir, 'custom-learned-skills'); + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + learned_skills_path: customLearnedDir + }) + ); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), '', { - HOME: testDir, USERPROFILE: testDir, - CLAUDE_SESSION_ID: `session-${shortId}` - }); - assert.strictEqual(result.code, 0); - - const updated = fs.readFileSync(sessionFile, 'utf8'); - // The timestamp should have been updated (no longer 09:00) - assert.ok(updated.includes('**Last Updated:**'), 'Should still have Last Updated field'); - assert.ok(result.stderr.includes('Updated session file'), 'Should log update'); - })) passed++; else failed++; - - if (await asyncTest('replaces blank template with summary when updating existing file', async () => { - const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const utils = require('../../scripts/lib/utils'); - const today = utils.getDateString(); - - const shortId = 'update02'; - const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - // Pre-existing file with blank template - const originalContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 09:00\n**Last Updated:** 09:00\n\n---\n\n## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`; - fs.writeFileSync(sessionFile, originalContent); - - // Create a transcript with user messages - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - const lines = [ - '{"type":"user","content":"Fix auth bug"}', - '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/auth.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir, USERPROFILE: testDir, - CLAUDE_SESSION_ID: `session-${shortId}` - }); - assert.strictEqual(result.code, 0); - - const updated = fs.readFileSync(sessionFile, 'utf8'); - // Should have replaced blank template with actual summary - assert.ok(!updated.includes('[Session context goes here]'), 'Should replace blank template'); - assert.ok(updated.includes('Fix auth bug'), 'Should include user message in summary'); - assert.ok(updated.includes('/src/auth.ts'), 'Should include modified file'); - })) passed++; else failed++; - - if (await asyncTest('always updates session summary content on session end', async () => { - const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const utils = require('../../scripts/lib/utils'); - const today = utils.getDateString(); - - const shortId = 'update03'; - const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - // Pre-existing file with already-filled summary - const existingContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 08:00\n**Last Updated:** 08:30\n\n---\n\n## Session Summary\n\n### Tasks\n- Previous task from earlier\n`; - fs.writeFileSync(sessionFile, existingContent); - - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - fs.writeFileSync(transcriptPath, '{"type":"user","content":"New task"}'); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir, USERPROFILE: testDir, - CLAUDE_SESSION_ID: `session-${shortId}` - }); - assert.strictEqual(result.code, 0); + const wrapperScript = createEvalWrapper(testDir, configPath); - const updated = fs.readFileSync(sessionFile, 'utf8'); - // Session summary should always be refreshed with current content (#317) - assert.ok(updated.includes('## Session Summary'), 'Should have Session Summary section'); - assert.ok(updated.includes('# Session:'), 'Should preserve session header'); - })) passed++; else failed++; + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.ok(fs.existsSync(customLearnedDir), 'Should create custom learned skills directory'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles invalid config JSON gracefully (uses defaults)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const lines = []; + for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + fs.writeFileSync(configPath, 'not valid json!!!'); + + const wrapperScript = createEvalWrapper(testDir, configPath); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.strictEqual(result.code, 0); + // Should log parse failure and fall back to default 10 → 5 msgs too short + assert.ok(result.stderr.includes('too short'), 'Should use defaults when config is invalid JSON'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - console.log('\nRound 23: pre-compact.js (glob specificity):'); + console.log('\nRound 23: session-end.js (update existing file path):'); - if (await asyncTest('only annotates *-session.tmp files, not other .tmp files', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-glob-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + if ( + await asyncTest('updates Last Updated timestamp in existing session file', async () => { + const testDir = createTestDir(); + const sessionsDir = path.join(testDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Get the expected filename + const utils = require('../../scripts/lib/utils'); + const today = utils.getDateString(); + + // Create a pre-existing session file with known timestamp + const shortId = 'update01'; + const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); + const originalContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 09:00\n**Last Updated:** 09:00\n\n---\n\n## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`; + fs.writeFileSync(sessionFile, originalContent); + + const result = await runScript(path.join(scriptsDir, 'session-end.js'), '', { + HOME: testDir, + USERPROFILE: testDir, + CLAUDE_SESSION_ID: `session-${shortId}` + }); + assert.strictEqual(result.code, 0); - // 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(otherTmpFile, 'some other data\n'); + const updated = fs.readFileSync(sessionFile, 'utf8'); + // The timestamp should have been updated (no longer 09:00) + assert.ok(updated.includes('**Last Updated:**'), 'Should still have Last Updated field'); + assert.ok(result.stderr.includes('Updated session file'), 'Should log update'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('replaces blank template with summary when updating existing file', async () => { + const testDir = createTestDir(); + const sessionsDir = path.join(testDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const utils = require('../../scripts/lib/utils'); + const today = utils.getDateString(); + + const shortId = 'update02'; + const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); + // Pre-existing file with blank template + const originalContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 09:00\n**Last Updated:** 09:00\n\n---\n\n## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`; + fs.writeFileSync(sessionFile, originalContent); + + // Create a transcript with user messages + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const lines = ['{"type":"user","content":"Fix auth bug"}', '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/auth.ts"}}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir, + USERPROFILE: testDir, + CLAUDE_SESSION_ID: `session-${shortId}` + }); + assert.strictEqual(result.code, 0); - try { - await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome + const updated = fs.readFileSync(sessionFile, 'utf8'); + // Should have replaced blank template with actual summary + assert.ok(!updated.includes('[Session context goes here]'), 'Should replace blank template'); + assert.ok(updated.includes('Fix auth bug'), 'Should include user message in summary'); + assert.ok(updated.includes('/src/auth.ts'), 'Should include modified file'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('always updates session summary content on session end', async () => { + const testDir = createTestDir(); + const sessionsDir = path.join(testDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const utils = require('../../scripts/lib/utils'); + const today = utils.getDateString(); + + const shortId = 'update03'; + const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); + // Pre-existing file with already-filled summary + const existingContent = `# Session: ${today}\n**Date:** ${today}\n**Started:** 08:00\n**Last Updated:** 08:30\n\n---\n\n## Session Summary\n\n### Tasks\n- Previous task from earlier\n`; + fs.writeFileSync(sessionFile, existingContent); + + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, '{"type":"user","content":"New task"}'); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir, + USERPROFILE: testDir, + CLAUDE_SESSION_ID: `session-${shortId}` }); + assert.strictEqual(result.code, 0); - const sessionContent = fs.readFileSync(sessionFile, 'utf8'); - const otherContent = fs.readFileSync(otherTmpFile, 'utf8'); + const updated = fs.readFileSync(sessionFile, 'utf8'); + // Session summary should always be refreshed with current content (#317) + assert.ok(updated.includes('## Session Summary'), 'Should have Session Summary section'); + assert.ok(updated.includes('# Session:'), 'Should preserve session header'); + }) + ) + passed++; + else failed++; - assert.ok(sessionContent.includes('Compaction occurred'), 'Should annotate session file'); - assert.strictEqual(otherContent, 'some other data\n', 'Should NOT annotate non-session .tmp file'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + console.log('\nRound 23: pre-compact.js (glob specificity):'); - if (await asyncTest('handles no active session files gracefully', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-nosession-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + if ( + await asyncTest('only annotates *-session.tmp files, not other .tmp files', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-glob-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // 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(otherTmpFile, 'some other data\n'); + + try { + await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); - try { - const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 with no session files'); - assert.ok(result.stderr.includes('[PreCompact]'), 'Should still log success'); - - // Compaction log should still be created - const logFile = path.join(sessionsDir, 'compaction-log.txt'); - assert.ok(fs.existsSync(logFile), 'Should create compaction log even with no sessions'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + const sessionContent = fs.readFileSync(sessionFile, 'utf8'); + const otherContent = fs.readFileSync(otherTmpFile, 'utf8'); + + assert.ok(sessionContent.includes('Compaction occurred'), 'Should annotate session file'); + assert.strictEqual(otherContent, 'some other data\n', 'Should NOT annotate non-session .tmp file'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles no active session files gracefully', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-nosession-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + try { + const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 with no session files'); + assert.ok(result.stderr.includes('[PreCompact]'), 'Should still log success'); + + // Compaction log should still be created + const logFile = path.join(sessionsDir, 'compaction-log.txt'); + assert.ok(fs.existsSync(logFile), 'Should create compaction log even with no sessions'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; console.log('\nRound 23: session-end.js (extractSessionSummary edge cases):'); - if (await asyncTest('handles transcript with only assistant messages (no user messages)', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Only assistant messages — no user messages - const lines = [ - '{"type":"assistant","message":{"content":[{"type":"text","text":"response"}]}}', - '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/app.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); + if ( + await asyncTest('handles transcript with only assistant messages (no user messages)', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Only assistant messages — no user messages + const lines = ['{"type":"assistant","message":{"content":[{"type":"text","text":"response"}]}}', '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/app.ts"}}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); - // With no user messages, extractSessionSummary returns null → blank template - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('[Session context goes here]'), 'Should use blank template when no user messages'); - } - } - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('extracts tool_use from assistant message content blocks', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Claude Code JSONL format: tool_use blocks inside assistant message content array - const lines = [ - '{"type":"user","content":"Edit config"}', - JSON.stringify({ - type: 'assistant', - message: { - content: [ - { type: 'text', text: 'I will edit the config.' }, - { type: 'tool_use', name: 'Edit', input: { file_path: '/src/config.ts' } }, - { type: 'tool_use', name: 'Write', input: { file_path: '/src/new.ts' } }, - ] + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); + + // With no user messages, extractSessionSummary returns null → blank template + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('[Session context goes here]'), 'Should use blank template when no user messages'); } - }), - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); + } + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('extracts tool_use from assistant message content blocks', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Claude Code JSONL format: tool_use blocks inside assistant message content array + const lines = [ + '{"type":"user","content":"Edit config"}', + JSON.stringify({ + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'I will edit the config.' }, + { type: 'tool_use', name: 'Edit', input: { file_path: '/src/config.ts' } }, + { type: 'tool_use', name: 'Write', input: { file_path: '/src/new.ts' } } + ] + } + }) + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('/src/config.ts'), 'Should extract file from nested tool_use block'); - assert.ok(content.includes('/src/new.ts'), 'Should extract Write file from nested block'); - assert.ok(content.includes('Edit'), 'Should list Edit in tools used'); + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('/src/config.ts'), 'Should extract file from nested tool_use block'); + assert.ok(content.includes('/src/new.ts'), 'Should extract Write file from nested block'); + assert.ok(content.includes('Edit'), 'Should list Edit in tools used'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ─── Round 24: suggest-compact interval fix, fd fallback, session-start maxAge ─── console.log('\nRound 24: suggest-compact.js (interval fix & fd fallback):'); - if (await asyncTest('periodic intervals are consistent with non-25-divisible threshold', async () => { - // Regression test: with threshold=13, periodic suggestions should fire at 38, 63, 88... - // (count - 13) % 25 === 0 → 38-13=25, 63-13=50, etc. - const sessionId = `test-interval-fix-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Pre-seed at 37 so next call = 38 (13 + 25 = 38) - fs.writeFileSync(counterFile, '37'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '13' - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('38 tool calls'), 'Should suggest at threshold(13) + 25 = 38'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('does not suggest at old-style multiples that skip threshold offset', async () => { - // With threshold=13, count=50 should NOT trigger (old behavior would: 50%25===0) - // New behavior: (50-13)%25 = 37%25 = 12 → no suggestion - const sessionId = `test-no-false-suggest-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - fs.writeFileSync(counterFile, '49'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId, - COMPACT_THRESHOLD: '13' - }); - assert.strictEqual(result.code, 0); - assert.ok(!result.stderr.includes('checkpoint'), 'Should NOT suggest at count=50 with threshold=13'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('fd fallback: handles corrupted counter file gracefully', async () => { - const sessionId = `test-corrupt-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // Write non-numeric data to trigger parseInt → NaN → reset to 1 - fs.writeFileSync(counterFile, 'corrupted data here!!!'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - assert.strictEqual(result.code, 0); - const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(newCount, 1, 'Should reset to 1 on corrupted file content'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; - - if (await asyncTest('handles counter at exact 1000000 boundary', async () => { - const sessionId = `test-boundary-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - try { - // 1000000 is the upper clamp boundary — should still increment - fs.writeFileSync(counterFile, '1000000'); - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - assert.strictEqual(result.code, 0); - const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); - assert.strictEqual(newCount, 1000001, 'Should increment from exactly 1000000'); - } finally { - try { fs.unlinkSync(counterFile); } catch { /* ignore */ } - } - })) passed++; else failed++; + if ( + await asyncTest('periodic intervals are consistent with non-25-divisible threshold', async () => { + // Regression test: with threshold=13, periodic suggestions should fire at 38, 63, 88... + // (count - 13) % 25 === 0 → 38-13=25, 63-13=50, etc. + const sessionId = `test-interval-fix-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Pre-seed at 37 so next call = 38 (13 + 25 = 38) + fs.writeFileSync(counterFile, '37'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '13' + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('38 tool calls'), 'Should suggest at threshold(13) + 25 = 38'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does not suggest at old-style multiples that skip threshold offset', async () => { + // With threshold=13, count=50 should NOT trigger (old behavior would: 50%25===0) + // New behavior: (50-13)%25 = 37%25 = 12 → no suggestion + const sessionId = `test-no-false-suggest-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + fs.writeFileSync(counterFile, '49'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId, + COMPACT_THRESHOLD: '13' + }); + assert.strictEqual(result.code, 0); + assert.ok(!result.stderr.includes('checkpoint'), 'Should NOT suggest at count=50 with threshold=13'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('fd fallback: handles corrupted counter file gracefully', async () => { + const sessionId = `test-corrupt-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // Write non-numeric data to trigger parseInt → NaN → reset to 1 + fs.writeFileSync(counterFile, 'corrupted data here!!!'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + assert.strictEqual(result.code, 0); + const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(newCount, 1, 'Should reset to 1 on corrupted file content'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles counter at exact 1000000 boundary', async () => { + const sessionId = `test-boundary-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + try { + // 1000000 is the upper clamp boundary — should still increment + fs.writeFileSync(counterFile, '1000000'); + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + assert.strictEqual(result.code, 0); + const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); + assert.strictEqual(newCount, 1000001, 'Should increment from exactly 1000000'); + } finally { + try { + fs.unlinkSync(counterFile); + } catch { + /* ignore */ + } + } + }) + ) + passed++; + else failed++; console.log('\nRound 24: post-edit-format.js (edge cases):'); - if (await asyncTest('passes through malformed JSON unchanged', async () => { - const malformedJson = '{"tool_input": {"file_path": "/test.ts"'; - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), malformedJson); - assert.strictEqual(result.code, 0); - // Should pass through the malformed data unchanged - assert.ok(result.stdout.includes(malformedJson), 'Should pass through malformed JSON'); - })) passed++; else failed++; - - if (await asyncTest('passes through data for non-JS/TS file extensions', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/path/to/file.py' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('file.py'), 'Should pass through for .py files'); - })) passed++; else failed++; + if ( + await asyncTest('passes through malformed JSON unchanged', async () => { + const malformedJson = '{"tool_input": {"file_path": "/test.ts"'; + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), malformedJson); + assert.strictEqual(result.code, 0); + // Should pass through the malformed data unchanged + assert.ok(result.stdout.includes(malformedJson), 'Should pass through malformed JSON'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through data for non-JS/TS file extensions', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/path/to/file.py' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('file.py'), 'Should pass through for .py files'); + }) + ) + passed++; + else failed++; console.log('\nRound 24: post-edit-typecheck.js (edge cases):'); - if (await asyncTest('skips typecheck for non-existent file and still passes through', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/deep/file.ts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('file.ts'), 'Should pass through for non-existent .ts file'); - })) passed++; else failed++; - - if (await asyncTest('passes through for non-TS extensions without running tsc', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/path/to/file.js' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('file.js'), 'Should pass through for .js file without running tsc'); - })) passed++; else failed++; + if ( + await asyncTest('skips typecheck for non-existent file and still passes through', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/deep/file.ts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('file.ts'), 'Should pass through for non-existent .ts file'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through for non-TS extensions without running tsc', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/path/to/file.js' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('file.js'), 'Should pass through for .js file without running tsc'); + }) + ) + passed++; + else failed++; console.log('\nRound 24: session-start.js (edge cases):'); - if (await asyncTest('exits 0 with empty sessions directory (no recent sessions)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-empty-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 with no sessions'); - // Should NOT inject any previous session data (stdout should be empty or minimal) - assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject when no sessions'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (await asyncTest('does not inject blank template session into context', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-blank-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Create a session file with the blank template marker - const today = new Date().toISOString().slice(0, 10); - const sessionFile = path.join(sessionsDir, `${today}-blank-session.tmp`); - fs.writeFileSync(sessionFile, '# Session\n[Session context goes here]\n'); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - // Should NOT inject blank template - assert.ok(!result.stdout.includes('Previous session summary'), 'Should skip blank template sessions'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('exits 0 with empty sessions directory (no recent sessions)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-empty-${Date.now()}`); + fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 with no sessions'); + // Should NOT inject any previous session data (stdout should be empty or minimal) + assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject when no sessions'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does not inject blank template session into context', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-blank-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Create a session file with the blank template marker + const today = new Date().toISOString().slice(0, 10); + const sessionFile = path.join(sessionsDir, `${today}-blank-session.tmp`); + fs.writeFileSync(sessionFile, '# Session\n[Session context goes here]\n'); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + // Should NOT inject blank template + assert.ok(!result.stdout.includes('Previous session summary'), 'Should skip blank template sessions'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ─── Round 25: post-edit-console-warn pass-through fix, check-console-log edge cases ─── console.log('\nRound 25: post-edit-console-warn.js (pass-through fix):'); - if (await asyncTest('stdout is exact byte match of stdin (no trailing newline)', async () => { - // Regression test: console.log(data) was replaced with process.stdout.write(data) - const stdinData = '{"tool_input":{"file_path":"/nonexistent/file.py"}}'; - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinData); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinData, 'stdout should exactly match stdin (no extra newline)'); - })) passed++; else failed++; - - if (await asyncTest('passes through malformed JSON unchanged without crash', async () => { - const malformed = '{"tool_input": {"file_path": "/test.ts"'; - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), malformed); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, malformed, 'Should pass through malformed JSON exactly'); - })) passed++; else failed++; - - if (await asyncTest('handles missing file_path in tool_input gracefully', async () => { - const stdinJson = JSON.stringify({ tool_input: {} }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinJson, 'Should pass through with missing file_path'); - })) passed++; else failed++; - - if (await asyncTest('passes through when file does not exist (readFile returns null)', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/deep/file.ts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinJson, 'Should pass through exactly when file not found'); - })) passed++; else failed++; + if ( + await asyncTest('stdout is exact byte match of stdin (no trailing newline)', async () => { + // Regression test: console.log(data) was replaced with process.stdout.write(data) + const stdinData = '{"tool_input":{"file_path":"/nonexistent/file.py"}}'; + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinData); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinData, 'stdout should exactly match stdin (no extra newline)'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through malformed JSON unchanged without crash', async () => { + const malformed = '{"tool_input": {"file_path": "/test.ts"'; + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), malformed); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, malformed, 'Should pass through malformed JSON exactly'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles missing file_path in tool_input gracefully', async () => { + const stdinJson = JSON.stringify({ tool_input: {} }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinJson, 'Should pass through with missing file_path'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through when file does not exist (readFile returns null)', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/deep/file.ts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinJson, 'Should pass through exactly when file not found'); + }) + ) + passed++; + else failed++; console.log('\nRound 25: check-console-log.js (edge cases):'); - if (await asyncTest('source has expected exclusion patterns', async () => { - // The EXCLUDED_PATTERNS array includes .test.ts, .spec.ts, etc. - const source = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); - // Verify the exclusion patterns exist (regex escapes use \. so check for the pattern names) - assert.ok(source.includes('EXCLUDED_PATTERNS'), 'Should have exclusion patterns array'); - assert.ok(/\.test\\\./.test(source), 'Should have test file exclusion pattern'); - assert.ok(/\.spec\\\./.test(source), 'Should have spec file exclusion pattern'); - assert.ok(source.includes('scripts'), 'Should exclude scripts/ directory'); - assert.ok(source.includes('__tests__'), 'Should exclude __tests__/ directory'); - assert.ok(source.includes('__mocks__'), 'Should exclude __mocks__/ directory'); - })) passed++; else failed++; - - if (await asyncTest('passes through data unchanged on non-git repo', async () => { - // In a temp dir with no git repo, the hook should pass through data unchanged - const testDir = createTestDir(); - const stdinData = '{"tool_input":"test"}'; - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData, { - // Use a non-git directory as CWD - HOME: testDir, USERPROFILE: testDir - }); - // Note: We're still running from a git repo, so isGitRepo() may still return true. - // This test verifies the script doesn't crash and passes through data. - assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes(stdinData), 'Should pass through data'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('exits 0 even when no stdin is provided', async () => { - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); - assert.strictEqual(result.code, 0, 'Should exit 0 with empty stdin'); - })) passed++; else failed++; + if ( + await asyncTest('source has expected exclusion patterns', async () => { + // The EXCLUDED_PATTERNS array includes .test.ts, .spec.ts, etc. + const source = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); + // Verify the exclusion patterns exist (regex escapes use \. so check for the pattern names) + assert.ok(source.includes('EXCLUDED_PATTERNS'), 'Should have exclusion patterns array'); + assert.ok(/\.test\\\./.test(source), 'Should have test file exclusion pattern'); + assert.ok(/\.spec\\\./.test(source), 'Should have spec file exclusion pattern'); + assert.ok(source.includes('scripts'), 'Should exclude scripts/ directory'); + assert.ok(source.includes('__tests__'), 'Should exclude __tests__/ directory'); + assert.ok(source.includes('__mocks__'), 'Should exclude __mocks__/ directory'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('passes through data unchanged on non-git repo', async () => { + // In a temp dir with no git repo, the hook should pass through data unchanged + const testDir = createTestDir(); + const stdinData = '{"tool_input":"test"}'; + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData, { + // Use a non-git directory as CWD + HOME: testDir, + USERPROFILE: testDir + }); + // Note: We're still running from a git repo, so isGitRepo() may still return true. + // This test verifies the script doesn't crash and passes through data. + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes(stdinData), 'Should pass through data'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exits 0 even when no stdin is provided', async () => { + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); + assert.strictEqual(result.code, 0, 'Should exit 0 with empty stdin'); + }) + ) + passed++; + else failed++; // ── Round 29: post-edit-format.js cwd fix and process.exit(0) consistency ── console.log('\nRound 29: post-edit-format.js (cwd and exit):'); - if (await asyncTest('source uses cwd based on file directory for npx', async () => { - const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - assert.ok(formatSource.includes('cwd:'), 'Should set cwd option for execFileSync'); - assert.ok(formatSource.includes('path.dirname'), 'cwd should use path.dirname of the file'); - assert.ok(formatSource.includes('path.resolve'), 'cwd should resolve the file path first'); - })) passed++; else failed++; - - if (await asyncTest('source calls process.exit(0) after writing output', async () => { - const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - assert.ok(formatSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); - })) passed++; else failed++; - - if (await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { - const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - assert.ok(formatSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write to avoid trailing newline'); - // Verify no console.log(data) for pass-through (console.error for warnings is OK) - const lines = formatSource.split('\n'); - const passThrough = lines.filter(l => /console\.log\(data\)/.test(l)); - assert.strictEqual(passThrough.length, 0, 'Should not use console.log(data) for pass-through'); - })) passed++; else failed++; + if ( + await asyncTest('source uses cwd based on file directory for npx', async () => { + const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); + assert.ok(formatSource.includes('cwd:'), 'Should set cwd option for execFileSync'); + assert.ok(formatSource.includes('path.dirname'), 'cwd should use path.dirname of the file'); + assert.ok(formatSource.includes('path.resolve'), 'cwd should resolve the file path first'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('source calls process.exit(0) after writing output', async () => { + const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); + assert.ok(formatSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { + const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); + assert.ok(formatSource.includes('process.stdout.write(result)'), 'Should use process.stdout.write to avoid trailing newline'); + // Verify no console.log for pass-through (console.error for warnings is OK) + const lines = formatSource.split('\n'); + const passThrough = lines.filter(l => /console\.log\((data|result)\)/.test(l)); + assert.strictEqual(passThrough.length, 0, 'Should not use console.log for pass-through'); + }) + ) + passed++; + else failed++; console.log('\nRound 29: post-edit-typecheck.js (exit and pass-through):'); - if (await asyncTest('source calls process.exit(0) after writing output', async () => { - const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); - assert.ok(tcSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); - })) passed++; else failed++; - - if (await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { - const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); - assert.ok(tcSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write'); - const lines = tcSource.split('\n'); - const passThrough = lines.filter(l => /console\.log\(data\)/.test(l)); - assert.strictEqual(passThrough.length, 0, 'Should not use console.log(data) for pass-through'); - })) passed++; else failed++; - - if (await asyncTest('exact stdout pass-through without trailing newline (typecheck)', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.py' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinJson, 'stdout should exactly match stdin (no trailing newline)'); - })) passed++; else failed++; - - if (await asyncTest('exact stdout pass-through without trailing newline (format)', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.py' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinJson, 'stdout should exactly match stdin (no trailing newline)'); - })) passed++; else failed++; + if ( + await asyncTest('source calls process.exit(0) after writing output', async () => { + const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); + assert.ok(tcSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { + const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); + assert.ok(tcSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write'); + const lines = tcSource.split('\n'); + const passThrough = lines.filter(l => /console\.log\(data\)/.test(l)); + assert.strictEqual(passThrough.length, 0, 'Should not use console.log(data) for pass-through'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exact stdout pass-through without trailing newline (typecheck)', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.py' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinJson, 'stdout should exactly match stdin (no trailing newline)'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exact stdout pass-through without trailing newline (format)', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.py' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinJson, 'stdout should exactly match stdin (no trailing newline)'); + }) + ) + passed++; + else failed++; console.log('\nRound 29: post-edit-console-warn.js (extension and exit):'); - if (await asyncTest('source calls process.exit(0) after writing output', async () => { - const cwSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-console-warn.js'), 'utf8'); - assert.ok(cwSource.includes('process.exit(0)'), 'Should call process.exit(0)'); - })) passed++; else failed++; - - if (await asyncTest('does NOT match .mts or .mjs extensions', async () => { - const stdinMts = JSON.stringify({ tool_input: { file_path: '/some/file.mts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinMts); - assert.strictEqual(result.code, 0); - // .mts is not in the regex /\.(ts|tsx|js|jsx)$/, so no console.log scan - assert.strictEqual(result.stdout, stdinMts, 'Should pass through .mts without scanning'); - assert.ok(!result.stderr.includes('console.log'), 'Should NOT scan .mts files for console.log'); - })) passed++; else failed++; - - if (await asyncTest('does NOT match uppercase .TS extension', async () => { - const stdinTS = JSON.stringify({ tool_input: { file_path: '/some/file.TS' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinTS); - assert.strictEqual(result.code, 0); - assert.strictEqual(result.stdout, stdinTS, 'Should pass through .TS without scanning'); - assert.ok(!result.stderr.includes('console.log'), 'Should NOT scan .TS (uppercase) files'); - })) passed++; else failed++; - - if (await asyncTest('detects console.log in commented-out code', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'commented.js'); - fs.writeFileSync(testFile, '// console.log("debug")\nconst x = 1;\n'); - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.strictEqual(result.code, 0); - // The regex /console\.log/ matches even in comments — this is intentional - assert.ok(result.stderr.includes('console.log'), 'Should detect console.log even in comments'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('source calls process.exit(0) after writing output', async () => { + const cwSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-console-warn.js'), 'utf8'); + assert.ok(cwSource.includes('process.exit(0)'), 'Should call process.exit(0)'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does NOT match .mts or .mjs extensions', async () => { + const stdinMts = JSON.stringify({ tool_input: { file_path: '/some/file.mts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinMts); + assert.strictEqual(result.code, 0); + // .mts is not in the regex /\.(ts|tsx|js|jsx)$/, so no console.log scan + assert.strictEqual(result.stdout, stdinMts, 'Should pass through .mts without scanning'); + assert.ok(!result.stderr.includes('console.log'), 'Should NOT scan .mts files for console.log'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does NOT match uppercase .TS extension', async () => { + const stdinTS = JSON.stringify({ tool_input: { file_path: '/some/file.TS' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinTS); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, stdinTS, 'Should pass through .TS without scanning'); + assert.ok(!result.stderr.includes('console.log'), 'Should NOT scan .TS (uppercase) files'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('detects console.log in commented-out code', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'commented.js'); + fs.writeFileSync(testFile, '// console.log("debug")\nconst x = 1;\n'); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + assert.strictEqual(result.code, 0); + // The regex /console\.log/ matches even in comments — this is intentional + assert.ok(result.stderr.includes('console.log'), 'Should detect console.log even in comments'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; console.log('\nRound 29: check-console-log.js (exclusion patterns and exit):'); - if (await asyncTest('source calls process.exit(0) after writing output', async () => { - const clSource = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); - // Should have at least 2 process.exit(0) calls (early return + end) - const exitCalls = clSource.match(/process\.exit\(0\)/g) || []; - assert.ok(exitCalls.length >= 2, `Should have at least 2 process.exit(0) calls, found ${exitCalls.length}`); - })) passed++; else failed++; - - if (await asyncTest('EXCLUDED_PATTERNS correctly excludes test files', async () => { - // Test the patterns directly by reading the source and evaluating the regex - const source = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); - // Verify the 6 exclusion patterns exist in the source (as regex literals with escapes) - const expectedSubstrings = ['test', 'spec', 'config', 'scripts', '__tests__', '__mocks__']; - for (const substr of expectedSubstrings) { - assert.ok(source.includes(substr), `Should include pattern containing "${substr}"`); - } - // Verify the array name exists - assert.ok(source.includes('EXCLUDED_PATTERNS'), 'Should have EXCLUDED_PATTERNS array'); - })) passed++; else failed++; - - if (await asyncTest('exclusion patterns match expected file paths', async () => { - // Recreate the EXCLUDED_PATTERNS from the source and test them - const EXCLUDED_PATTERNS = [ - /\.test\.[jt]sx?$/, - /\.spec\.[jt]sx?$/, - /\.config\.[jt]s$/, - /scripts\//, - /__tests__\//, - /__mocks__\//, - ]; - // These SHOULD be excluded - const excluded = [ - 'src/utils.test.ts', 'src/utils.test.js', 'src/utils.test.tsx', 'src/utils.test.jsx', - 'src/utils.spec.ts', 'src/utils.spec.js', - 'src/utils.config.ts', 'src/utils.config.js', - 'scripts/hooks/session-end.js', - '__tests__/utils.ts', - '__mocks__/api.ts', - ]; - for (const f of excluded) { - const matches = EXCLUDED_PATTERNS.some(p => p.test(f)); - assert.ok(matches, `Expected "${f}" to be excluded but it was not`); - } - // These should NOT be excluded - const notExcluded = [ - 'src/utils.ts', 'src/main.tsx', 'src/app.js', - 'src/test.component.ts', // "test" in name but not .test. pattern - 'src/config.ts', // "config" in name but not .config. pattern - ]; - for (const f of notExcluded) { - const matches = EXCLUDED_PATTERNS.some(p => p.test(f)); - assert.ok(!matches, `Expected "${f}" to NOT be excluded but it was`); - } - })) passed++; else failed++; + if ( + await asyncTest('source calls process.exit(0) after writing output', async () => { + const clSource = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); + // Should have at least 2 process.exit(0) calls (early return + end) + const exitCalls = clSource.match(/process\.exit\(0\)/g) || []; + assert.ok(exitCalls.length >= 2, `Should have at least 2 process.exit(0) calls, found ${exitCalls.length}`); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('EXCLUDED_PATTERNS correctly excludes test files', async () => { + // Test the patterns directly by reading the source and evaluating the regex + const source = fs.readFileSync(path.join(scriptsDir, 'check-console-log.js'), 'utf8'); + // Verify the 6 exclusion patterns exist in the source (as regex literals with escapes) + const expectedSubstrings = ['test', 'spec', 'config', 'scripts', '__tests__', '__mocks__']; + for (const substr of expectedSubstrings) { + assert.ok(source.includes(substr), `Should include pattern containing "${substr}"`); + } + // Verify the array name exists + assert.ok(source.includes('EXCLUDED_PATTERNS'), 'Should have EXCLUDED_PATTERNS array'); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('exclusion patterns match expected file paths', async () => { + // Recreate the EXCLUDED_PATTERNS from the source and test them + const EXCLUDED_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.config\.[jt]s$/, /scripts\//, /__tests__\//, /__mocks__\//]; + // These SHOULD be excluded + const excluded = [ + 'src/utils.test.ts', + 'src/utils.test.js', + 'src/utils.test.tsx', + 'src/utils.test.jsx', + 'src/utils.spec.ts', + 'src/utils.spec.js', + 'src/utils.config.ts', + 'src/utils.config.js', + 'scripts/hooks/session-end.js', + '__tests__/utils.ts', + '__mocks__/api.ts' + ]; + for (const f of excluded) { + const matches = EXCLUDED_PATTERNS.some(p => p.test(f)); + assert.ok(matches, `Expected "${f}" to be excluded but it was not`); + } + // These should NOT be excluded + const notExcluded = [ + 'src/utils.ts', + 'src/main.tsx', + 'src/app.js', + 'src/test.component.ts', // "test" in name but not .test. pattern + 'src/config.ts' // "config" in name but not .config. pattern + ]; + for (const f of notExcluded) { + const matches = EXCLUDED_PATTERNS.some(p => p.test(f)); + assert.ok(!matches, `Expected "${f}" to NOT be excluded but it was`); + } + }) + ) + passed++; + else failed++; console.log('\nRound 29: run-all.js test runner improvements:'); - if (await asyncTest('test runner uses spawnSync to capture stderr on success', async () => { - const runAllSource = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8'); - assert.ok(runAllSource.includes('spawnSync'), 'Should use spawnSync instead of execSync'); - assert.ok(!runAllSource.includes('execSync'), 'Should not use execSync'); - // Verify it shows stderr - assert.ok(runAllSource.includes('stderr'), 'Should handle stderr output'); - })) passed++; else failed++; + if ( + await asyncTest('test runner uses spawnSync to capture stderr on success', async () => { + const runAllSource = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8'); + assert.ok(runAllSource.includes('spawnSync'), 'Should use spawnSync instead of execSync'); + assert.ok(!runAllSource.includes('execSync'), 'Should not use execSync'); + // Verify it shows stderr + assert.ok(runAllSource.includes('stderr'), 'Should handle stderr output'); + }) + ) + passed++; + else failed++; // ── Round 32: post-edit-typecheck special characters & check-console-log ── console.log('\nRound 32: post-edit-typecheck (special character paths):'); - if (await asyncTest('handles file path with spaces gracefully', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'my file.ts'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle spaces in path'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles file path with shell metacharacters safely', async () => { - const testDir = createTestDir(); - // File name with characters that could be dangerous in shell contexts - const testFile = path.join(testDir, 'test$(echo).ts'); - fs.writeFileSync(testFile, 'const x: number = 1;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should not crash on shell metacharacters'); - // execFileSync prevents shell injection — just verify no crash - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data safely'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles .tsx file extension', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'component.tsx'); - fs.writeFileSync(testFile, 'const App = () =>
Hello
;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle .tsx files'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('handles file path with spaces gracefully', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'my file.ts'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle spaces in path'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles file path with shell metacharacters safely', async () => { + const testDir = createTestDir(); + // File name with characters that could be dangerous in shell contexts + const testFile = path.join(testDir, 'test$(echo).ts'); + fs.writeFileSync(testFile, 'const x: number = 1;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should not crash on shell metacharacters'); + // execFileSync prevents shell injection — just verify no crash + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data safely'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles .tsx file extension', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'component.tsx'); + fs.writeFileSync(testFile, 'const App = () =>
Hello
;'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle .tsx files'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; console.log('\nRound 32: check-console-log (edge cases):'); - if (await asyncTest('passes through data when git commands fail', async () => { - // Run from a non-git directory - const testDir = createTestDir(); - const stdinData = JSON.stringify({ tool_name: 'Write', tool_input: {} }); - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); - assert.strictEqual(result.code, 0, 'Should exit 0'); - assert.ok(result.stdout.includes('tool_name'), 'Should pass through stdin'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles very large stdin within limit', async () => { - // Send just under the 1MB limit - const largePayload = JSON.stringify({ tool_name: 'x'.repeat(500000) }); - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), largePayload); - assert.strictEqual(result.code, 0, 'Should handle large stdin'); - })) passed++; else failed++; + if ( + await asyncTest('passes through data when git commands fail', async () => { + // Run from a non-git directory + const testDir = createTestDir(); + const stdinData = JSON.stringify({ tool_name: 'Write', tool_input: {} }); + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stdout.includes('tool_name'), 'Should pass through stdin'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles very large stdin within limit', async () => { + // Send just under the 1MB limit + const largePayload = JSON.stringify({ tool_name: 'x'.repeat(500000) }); + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), largePayload); + assert.strictEqual(result.code, 0, 'Should handle large stdin'); + }) + ) + passed++; + else failed++; console.log('\nRound 32: post-edit-console-warn (additional edge cases):'); - if (await asyncTest('handles file with only console.error (no warning)', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'errors-only.ts'); - fs.writeFileSync(testFile, 'console.error("this is fine");\nconsole.warn("also fine");'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.ok(!result.stderr.includes('WARNING'), 'Should NOT warn for console.error/warn only'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('handles file with only console.error (no warning)', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'errors-only.ts'); + fs.writeFileSync(testFile, 'console.error("this is fine");\nconsole.warn("also fine");'); - if (await asyncTest('handles null tool_input gracefully', async () => { - const stdinJson = JSON.stringify({ tool_input: null }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle null tool_input'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); - })) passed++; else failed++; + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + assert.ok(!result.stderr.includes('WARNING'), 'Should NOT warn for console.error/warn only'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles null tool_input gracefully', async () => { + const stdinJson = JSON.stringify({ tool_input: null }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle null tool_input'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through data'); + }) + ) + passed++; + else failed++; console.log('\nRound 32: session-end.js (empty transcript):'); - if (await asyncTest('handles completely empty transcript file', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'empty.jsonl'); - fs.writeFileSync(transcriptPath, ''); + if ( + await asyncTest('handles completely empty transcript file', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'empty.jsonl'); + fs.writeFileSync(transcriptPath, ''); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle empty transcript'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('handles transcript with only whitespace lines', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'whitespace.jsonl'); - fs.writeFileSync(transcriptPath, ' \n\n \n'); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should handle whitespace-only transcript'); - cleanupTestDir(testDir); - })) passed++; else failed++; + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle empty transcript'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('handles transcript with only whitespace lines', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'whitespace.jsonl'); + fs.writeFileSync(transcriptPath, ' \n\n \n'); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should handle whitespace-only transcript'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ── Round 38: evaluate-session.js tilde expansion & missing config ── console.log('\nRound 38: evaluate-session.js (tilde expansion & missing config):'); - if (await asyncTest('expands ~ in learned_skills_path to home directory', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // 1 user message — below threshold, but we only need to verify directory creation - fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); - - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - // Use ~ prefix — should expand to the HOME dir we set - fs.writeFileSync(configPath, JSON.stringify({ - learned_skills_path: '~/test-tilde-skills' - })); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // ~ should expand to os.homedir() which during the script run is the real home - // The script creates the directory via ensureDir — check that it attempted to - // create a directory starting with the home dir, not a literal ~/ - // Verify the literal ~/test-tilde-skills was NOT created - assert.ok( - !fs.existsSync(path.join(testDir, '~', 'test-tilde-skills')), - 'Should NOT create literal ~/test-tilde-skills directory' - ); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('does NOT expand ~ in middle of learned_skills_path', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); - - const midTildeDir = path.join(testDir, 'some~path', 'skills'); - const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); - fs.mkdirSync(skillsDir, { recursive: true }); - const configPath = path.join(skillsDir, 'config.json'); - // Path with ~ in the middle — should NOT be expanded - fs.writeFileSync(configPath, JSON.stringify({ - learned_skills_path: midTildeDir - })); - - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // The directory with ~ in the middle should be created as-is - assert.ok( - fs.existsSync(midTildeDir), - 'Should create directory with ~ in middle of path unchanged' - ); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('uses defaults when config file does not exist', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // 5 user messages — below default threshold of 10 - const lines = []; - for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); - fs.writeFileSync(transcriptPath, lines.join('\n')); - - // Point config to a non-existent file - const configPath = path.join(testDir, 'nonexistent', 'config.json'); - const wrapperScript = createEvalWrapper(testDir, configPath); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(wrapperScript, stdinJson, { - HOME: testDir, USERPROFILE: testDir - }); - assert.strictEqual(result.code, 0); - // With no config file, default min_session_length=10 applies - // 5 messages should be "too short" - assert.ok( - result.stderr.includes('too short'), - 'Should use default threshold (10) when config file missing' - ); - // No error messages about missing config - assert.ok( - !result.stderr.includes('Failed to parse config'), - 'Should NOT log config parse error for missing file' - ); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('expands ~ in learned_skills_path to home directory', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // 1 user message — below threshold, but we only need to verify directory creation + fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); + + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + // Use ~ prefix — should expand to the HOME dir we set + fs.writeFileSync( + configPath, + JSON.stringify({ + learned_skills_path: '~/test-tilde-skills' + }) + ); - // Round 41: pre-compact.js (multiple session files) - console.log('\nRound 41: pre-compact.js (multiple session files):'); + const wrapperScript = createEvalWrapper(testDir, configPath); - if (await asyncTest('annotates only the newest session file when multiple exist', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-multi-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - // 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'); - // 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'); - - try { - const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.strictEqual(result.code, 0); + // ~ should expand to os.homedir() which during the script run is the real home + // The script creates the directory via ensureDir — check that it attempted to + // create a directory starting with the home dir, not a literal ~/ + // Verify the literal ~/test-tilde-skills was NOT created + assert.ok(!fs.existsSync(path.join(testDir, '~', 'test-tilde-skills')), 'Should NOT create literal ~/test-tilde-skills directory'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('does NOT expand ~ in middle of learned_skills_path', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, '{"type":"user","content":"msg"}'); + + const midTildeDir = path.join(testDir, 'some~path', 'skills'); + const skillsDir = path.join(testDir, 'skills', 'continuous-learning'); + fs.mkdirSync(skillsDir, { recursive: true }); + const configPath = path.join(skillsDir, 'config.json'); + // Path with ~ in the middle — should NOT be expanded + fs.writeFileSync( + configPath, + JSON.stringify({ + learned_skills_path: midTildeDir + }) + ); + + const wrapperScript = createEvalWrapper(testDir, configPath); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir }); assert.strictEqual(result.code, 0); + // The directory with ~ in the middle should be created as-is + assert.ok(fs.existsSync(midTildeDir), 'Should create directory with ~ in middle of path unchanged'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('uses defaults when config file does not exist', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // 5 user messages — below default threshold of 10 + const lines = []; + for (let i = 0; i < 5; i++) lines.push(`{"type":"user","content":"msg${i}"}`); + fs.writeFileSync(transcriptPath, lines.join('\n')); + + // Point config to a non-existent file + const configPath = path.join(testDir, 'nonexistent', 'config.json'); + const wrapperScript = createEvalWrapper(testDir, configPath); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(wrapperScript, stdinJson, { + HOME: testDir, + USERPROFILE: testDir + }); + assert.strictEqual(result.code, 0); + // With no config file, default min_session_length=10 applies + // 5 messages should be "too short" + assert.ok(result.stderr.includes('too short'), 'Should use default threshold (10) when config file missing'); + // No error messages about missing config + assert.ok(!result.stderr.includes('Failed to parse config'), 'Should NOT log config parse error for missing file'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; - const newerContent = fs.readFileSync(newerSession, 'utf8'); - const olderContent = fs.readFileSync(olderSession, 'utf8'); + // Round 41: pre-compact.js (multiple session files) + console.log('\nRound 41: pre-compact.js (multiple session files):'); - // findFiles sorts by mtime newest first, so sessions[0] is the newest - assert.ok( - newerContent.includes('Compaction occurred'), - 'Should annotate the newest session file' - ); - assert.strictEqual( - olderContent, - '# Older Session\n', - 'Should NOT annotate older session files' - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('annotates only the newest session file when multiple exist', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-multi-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // 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'); + // 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'); + + try { + const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + + const newerContent = fs.readFileSync(newerSession, 'utf8'); + const olderContent = fs.readFileSync(olderSession, 'utf8'); + + // findFiles sorts by mtime newest first, so sessions[0] is the newest + assert.ok(newerContent.includes('Compaction occurred'), 'Should annotate the newest session file'); + assert.strictEqual(olderContent, '# Older Session\n', 'Should NOT annotate older session files'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // Round 40: session-end.js (newline collapse in markdown list items) console.log('\nRound 40: session-end.js (newline collapse):'); - if (await asyncTest('collapses newlines in user messages to single-line markdown items', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); + if ( + await asyncTest('collapses newlines in user messages to single-line markdown items', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // User message containing newlines that would break markdown list - const lines = [ - JSON.stringify({ type: 'user', content: 'Please help me with:\n1. Task one\n2. Task two\n3. Task three' }), - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); + // User message containing newlines that would break markdown list + const lines = [JSON.stringify({ type: 'user', content: 'Please help me with:\n1. Task one\n2. Task two\n3. Task three' })]; + fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0); - - // Find the session file and verify newlines were collapsed - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Each task should be a single-line markdown list item - const taskLines = content.split('\n').filter(l => l.startsWith('- ')); - for (const line of taskLines) { - assert.ok( - !line.includes('\n'), - 'Task list items should be single-line' - ); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0); + + // Find the session file and verify newlines were collapsed + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Each task should be a single-line markdown list item + const taskLines = content.split('\n').filter(l => l.startsWith('- ')); + for (const line of taskLines) { + assert.ok(!line.includes('\n'), 'Task list items should be single-line'); + } + // Newlines should be replaced with spaces + assert.ok(content.includes('Please help me with: 1. Task one 2. Task two'), `Newlines should be collapsed to spaces, got: ${content.substring(0, 500)}`); } - // Newlines should be replaced with spaces - assert.ok( - content.includes('Please help me with: 1. Task one 2. Task two'), - `Newlines should be collapsed to spaces, got: ${content.substring(0, 500)}` - ); } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ── Round 44: session-start.js empty session file ── console.log('\nRound 44: session-start.js (empty session file):'); - if (await asyncTest('does not inject empty session file content into context', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-empty-file-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Create a 0-byte session file (simulates truncated/corrupted write) - const today = new Date().toISOString().slice(0, 10); - const sessionFile = path.join(sessionsDir, `${today}-empty0000-session.tmp`); - fs.writeFileSync(sessionFile, ''); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 with empty session file'); - // readFile returns '' (falsy) → the if (content && ...) guard skips injection - assert.ok( - !result.stdout.includes('Previous session summary'), - 'Should NOT inject empty string into context' - ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('does not inject empty session file content into context', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-empty-file-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Create a 0-byte session file (simulates truncated/corrupted write) + const today = new Date().toISOString().slice(0, 10); + const sessionFile = path.join(sessionsDir, `${today}-empty0000-session.tmp`); + fs.writeFileSync(sessionFile, ''); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 with empty session file'); + // readFile returns '' (falsy) → the if (content && ...) guard skips injection + assert.ok(!result.stdout.includes('Previous session summary'), 'Should NOT inject empty string into context'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 49: typecheck extension matching and session-end conditional sections ── console.log('\nRound 49: post-edit-typecheck.js (extension edge cases):'); - if (await asyncTest('.d.ts files match the TS regex and trigger typecheck path', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'types.d.ts'); - fs.writeFileSync(testFile, 'declare const x: number;'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for .d.ts file'); - assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); - cleanupTestDir(testDir); - })) passed++; else failed++; - - if (await asyncTest('.mts extension does not trigger typecheck', async () => { - const stdinJson = JSON.stringify({ tool_input: { file_path: '/project/utils.mts' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for .mts file'); - assert.strictEqual(result.stdout, stdinJson, 'Should pass through .mts unchanged'); - })) passed++; else failed++; + if ( + await asyncTest('.d.ts files match the TS regex and trigger typecheck path', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'types.d.ts'); + fs.writeFileSync(testFile, 'declare const x: number;'); - console.log('\nRound 49: session-end.js (conditional summary sections):'); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for .d.ts file'); + assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('.mts extension does not trigger typecheck', async () => { + const stdinJson = JSON.stringify({ tool_input: { file_path: '/project/utils.mts' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + assert.strictEqual(result.code, 0, 'Should exit 0 for .mts file'); + assert.strictEqual(result.stdout, stdinJson, 'Should pass through .mts unchanged'); + }) + ) + passed++; + else failed++; - if (await asyncTest('summary omits Files Modified and Tools Used when none found', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-notools-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Only user messages — no tool_use entries at all - const lines = [ - '{"type":"user","content":"How does authentication work?"}', - '{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}' - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); + console.log('\nRound 49: session-end.js (conditional summary sections):'); - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - assert.ok(content.includes('authentication'), 'Should include user message'); - assert.ok(!content.includes('### Files Modified'), 'Should omit Files Modified when empty'); - assert.ok(!content.includes('### Tools Used'), 'Should omit Tools Used when empty'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - cleanupTestDir(testDir); - } - })) passed++; else failed++; + if ( + await asyncTest('summary omits Files Modified and Tools Used when none found', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-notools-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Only user messages — no tool_use entries at all + const lines = ['{"type":"user","content":"How does authentication work?"}', '{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + assert.ok(content.includes('authentication'), 'Should include user message'); + assert.ok(!content.includes('### Files Modified'), 'Should omit Files Modified when empty'); + assert.ok(!content.includes('### Tools Used'), 'Should omit Tools Used when empty'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + cleanupTestDir(testDir); + } + }) + ) + passed++; + else failed++; // ── Round 50: alias reporting, parallel compaction, graceful degradation ── console.log('\nRound 50: session-start.js (alias reporting):'); - if (await asyncTest('reports available session aliases on startup', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-alias-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Pre-populate the aliases file - fs.writeFileSync(path.join(isoHome, '.claude', 'session-aliases.json'), JSON.stringify({ - version: '1.0', - aliases: { - 'my-feature': { sessionPath: '/sessions/feat', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), title: null }, - 'bug-fix': { sessionPath: '/sessions/fix', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), title: null } - }, - metadata: { totalCount: 2, lastUpdated: new Date().toISOString() } - })); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('alias'), 'Should mention aliases in stderr'); - assert.ok( - result.stderr.includes('my-feature') || result.stderr.includes('bug-fix'), - 'Should list at least one alias name' + if ( + await asyncTest('reports available session aliases on startup', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-alias-${Date.now()}`); + fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Pre-populate the aliases file + fs.writeFileSync( + path.join(isoHome, '.claude', 'session-aliases.json'), + JSON.stringify({ + version: '1.0', + aliases: { + 'my-feature': { sessionPath: '/sessions/feat', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), title: null }, + 'bug-fix': { sessionPath: '/sessions/fix', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), title: null } + }, + metadata: { totalCount: 2, lastUpdated: new Date().toISOString() } + }) ); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; - - console.log('\nRound 50: pre-compact.js (parallel execution):'); - if (await asyncTest('parallel compaction runs all append to log without loss', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-compact-par-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('alias'), 'Should mention aliases in stderr'); + assert.ok(result.stderr.includes('my-feature') || result.stderr.includes('bug-fix'), 'Should list at least one alias name'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; - try { - const promises = Array(3).fill(null).map(() => - runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }) - ); - const results = await Promise.all(promises); - results.forEach((r, i) => assert.strictEqual(r.code, 0, `Run ${i} should exit 0`)); + console.log('\nRound 50: pre-compact.js (parallel execution):'); - const logFile = path.join(sessionsDir, 'compaction-log.txt'); - assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); - const content = fs.readFileSync(logFile, 'utf8'); - const entries = (content.match(/Context compaction triggered/g) || []).length; - assert.strictEqual(entries, 3, `Should have 3 log entries, got ${entries}`); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('parallel compaction runs all append to log without loss', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-compact-par-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + try { + const promises = Array(3) + .fill(null) + .map(() => + runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }) + ); + const results = await Promise.all(promises); + results.forEach((r, i) => assert.strictEqual(r.code, 0, `Run ${i} should exit 0`)); + + const logFile = path.join(sessionsDir, 'compaction-log.txt'); + assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); + const content = fs.readFileSync(logFile, 'utf8'); + const entries = (content.match(/Context compaction triggered/g) || []).length; + assert.strictEqual(entries, 3, `Should have 3 log entries, got ${entries}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; console.log('\nRound 50: session-start.js (graceful degradation):'); - if (await asyncTest('exits 0 when sessions path is a file (not a directory)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-blocked-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude'), { recursive: true }); - // Block sessions dir creation by placing a file at that path - fs.writeFileSync(path.join(isoHome, '.claude', 'sessions'), 'blocked'); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 even when sessions dir is blocked'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('exits 0 when sessions path is a file (not a directory)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-blocked-${Date.now()}`); + fs.mkdirSync(path.join(isoHome, '.claude'), { recursive: true }); + // Block sessions dir creation by placing a file at that path + fs.writeFileSync(path.join(isoHome, '.claude', 'sessions'), 'blocked'); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 even when sessions dir is blocked'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 53: console-warn max matches and format non-existent file ── console.log('\nRound 53: post-edit-console-warn.js (max matches truncation):'); - if (await asyncTest('reports maximum 5 console.log matches per file', async () => { - const testDir = createTestDir(); - const testFile = path.join(testDir, 'many-logs.js'); - const lines = Array(7).fill(null).map((_, i) => - `console.log("debug line ${i + 1}");` - ); - fs.writeFileSync(testFile, lines.join('\n')); + if ( + await asyncTest('reports maximum 5 console.log matches per file', async () => { + const testDir = createTestDir(); + const testFile = path.join(testDir, 'many-logs.js'); + const lines = Array(7) + .fill(null) + .map((_, i) => `console.log("debug line ${i + 1}");`); + fs.writeFileSync(testFile, lines.join('\n')); - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0'); - // Count line number reports in stderr (format: "N: console.log(...)") - const lineReports = (result.stderr.match(/^\d+:/gm) || []).length; - assert.strictEqual(lineReports, 5, `Should report max 5 matches, got ${lineReports}`); - cleanupTestDir(testDir); - })) passed++; else failed++; + assert.strictEqual(result.code, 0, 'Should exit 0'); + // Count line number reports in stderr (format: "N: console.log(...)") + const lineReports = (result.stderr.match(/^\d+:/gm) || []).length; + assert.strictEqual(lineReports, 5, `Should report max 5 matches, got ${lineReports}`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; console.log('\nRound 53: post-edit-format.js (non-existent file):'); - if (await asyncTest('passes through data for non-existent .tsx file path', async () => { - const stdinJson = JSON.stringify({ - tool_input: { file_path: '/nonexistent/path/file.tsx' } - }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + if ( + await asyncTest('passes through data for non-existent .tsx file path', async () => { + const stdinJson = JSON.stringify({ + tool_input: { file_path: '/nonexistent/path/file.tsx' } + }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for non-existent file'); - assert.strictEqual(result.stdout, stdinJson, 'Should pass through stdin data unchanged'); - })) passed++; else failed++; + assert.strictEqual(result.code, 0, 'Should exit 0 for non-existent file'); + assert.strictEqual(result.stdout, stdinJson, 'Should pass through stdin data unchanged'); + }) + ) + passed++; + else failed++; // ── Round 55: maxAge boundary, multi-session injection, stdin overflow ── console.log('\nRound 55: session-start.js (maxAge 7-day boundary):'); - if (await asyncTest('excludes session files older than 7 days', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-7day-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - // Create session file 6.9 days old (should be INCLUDED by maxAge:7) - const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp'); - fs.writeFileSync(recentFile, '# Recent Session\n\nRECENT CONTENT HERE'); - const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000); - fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo); - - // Create session file 8 days old (should be EXCLUDED by maxAge:7) - const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp'); - fs.writeFileSync(oldFile, '# Old Session\n\nOLD CONTENT SHOULD NOT APPEAR'); - const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); - fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('1 recent session'), - `Should find 1 recent session (6.9-day included, 8-day excluded), stderr: ${result.stderr}`); - assert.ok(result.stdout.includes('RECENT CONTENT HERE'), - 'Should inject the 6.9-day-old session content'); - assert.ok(!result.stdout.includes('OLD CONTENT SHOULD NOT APPEAR'), - 'Should NOT inject the 8-day-old session content'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('excludes session files older than 7 days', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-7day-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + // Create session file 6.9 days old (should be INCLUDED by maxAge:7) + const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp'); + fs.writeFileSync(recentFile, '# Recent Session\n\nRECENT CONTENT HERE'); + const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000); + fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo); + + // Create session file 8 days old (should be EXCLUDED by maxAge:7) + const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp'); + fs.writeFileSync(oldFile, '# Old Session\n\nOLD CONTENT SHOULD NOT APPEAR'); + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('1 recent session'), `Should find 1 recent session (6.9-day included, 8-day excluded), stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content'); + assert.ok(!result.stdout.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; console.log('\nRound 55: session-start.js (newest session selection):'); - if (await asyncTest('injects newest session when multiple recent sessions exist', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-start-multi-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - - const now = Date.now(); - - // Create older session (2 days ago) - const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp'); - fs.writeFileSync(olderSession, '# Older Session\n\nOLDER_CONTEXT_MARKER'); - fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000)); - - // Create newer session (1 day ago) - const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp'); - fs.writeFileSync(newerSession, '# Newer Session\n\nNEWER_CONTEXT_MARKER'); - fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000)); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - assert.ok(result.stderr.includes('2 recent session'), - `Should find 2 recent sessions, stderr: ${result.stderr}`); - // Should inject the NEWER session, not the older one - assert.ok(result.stdout.includes('NEWER_CONTEXT_MARKER'), - 'Should inject the newest session content'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('injects newest session when multiple recent sessions exist', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-start-multi-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + + const now = Date.now(); + + // Create older session (2 days ago) + const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp'); + fs.writeFileSync(olderSession, '# Older Session\n\nOLDER_CONTEXT_MARKER'); + fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000)); + + // Create newer session (1 day ago) + const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp'); + fs.writeFileSync(newerSession, '# Newer Session\n\nNEWER_CONTEXT_MARKER'); + fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000)); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('2 recent session'), `Should find 2 recent sessions, stderr: ${result.stderr}`); + // Should inject the NEWER session, not the older one + assert.ok(result.stdout.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; console.log('\nRound 55: session-end.js (stdin overflow):'); - if (await asyncTest('handles stdin exceeding MAX_STDIN (1MB) gracefully', async () => { - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Create a minimal valid transcript so env var fallback works - fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user', content: 'Overflow test' }) + '\n'); + if ( + await asyncTest('handles stdin exceeding MAX_STDIN (1MB) gracefully', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Create a minimal valid transcript so env var fallback works + fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user', content: 'Overflow test' }) + '\n'); - // Create stdin > 1MB: truncated JSON will be invalid → falls back to env var - const oversizedPayload = '{"transcript_path":"' + 'x'.repeat(1048600) + '"}'; + // Create stdin > 1MB: truncated JSON will be invalid → falls back to env var + const oversizedPayload = '{"transcript_path":"' + 'x'.repeat(1048600) + '"}'; - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), oversizedPayload, { - CLAUDE_TRANSCRIPT_PATH: transcriptPath - }); - assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Truncated JSON → JSON.parse throws → falls back to env var → creates session file - assert.ok( - result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), - `Should create/update session file via env var fallback, stderr: ${result.stderr}` - ); - } finally { - cleanupTestDir(testDir); - } - })) passed++; else failed++; + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), oversizedPayload, { + CLAUDE_TRANSCRIPT_PATH: transcriptPath + }); + assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); + // Truncated JSON → JSON.parse throws → falls back to env var → creates session file + assert.ok(result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), `Should create/update session file via env var fallback, stderr: ${result.stderr}`); + } finally { + cleanupTestDir(testDir); + } + }) + ) + passed++; + else failed++; // ── Round 56: typecheck tsconfig walk-up, suggest-compact fallback path ── console.log('\nRound 56: post-edit-typecheck.js (tsconfig in parent directory):'); - if (await asyncTest('walks up directory tree to find tsconfig.json in grandparent', async () => { - const testDir = createTestDir(); - // Place tsconfig at the TOP level, file is nested 2 levels deep - fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { strict: false, noEmit: true } - })); - const deepDir = path.join(testDir, 'src', 'components'); - fs.mkdirSync(deepDir, { recursive: true }); - const testFile = path.join(deepDir, 'widget.ts'); - fs.writeFileSync(testFile, 'export const value: number = 42;\n'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - - assert.strictEqual(result.code, 0, 'Should exit 0 after walking up to find tsconfig'); - // Core assertion: stdin must pass through regardless of whether tsc ran - const parsed = JSON.parse(result.stdout); - assert.strictEqual(parsed.tool_input.file_path, testFile, - 'Should pass through original stdin data with file_path intact'); - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('walks up directory tree to find tsconfig.json in grandparent', async () => { + const testDir = createTestDir(); + // Place tsconfig at the TOP level, file is nested 2 levels deep + fs.writeFileSync( + path.join(testDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: false, noEmit: true } + }) + ); + const deepDir = path.join(testDir, 'src', 'components'); + fs.mkdirSync(deepDir, { recursive: true }); + const testFile = path.join(deepDir, 'widget.ts'); + fs.writeFileSync(testFile, 'export const value: number = 42;\n'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + + assert.strictEqual(result.code, 0, 'Should exit 0 after walking up to find tsconfig'); + // Core assertion: stdin must pass through regardless of whether tsc ran + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.tool_input.file_path, testFile, 'Should pass through original stdin data with file_path intact'); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; console.log('\nRound 56: suggest-compact.js (counter file as directory — fallback path):'); - if (await asyncTest('exits 0 when counter file path is occupied by a directory', async () => { - const sessionId = `dirblock-${Date.now()}`; - const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); - // Create a DIRECTORY at the counter file path — openSync('a+') will fail with EISDIR - fs.mkdirSync(counterFile); + if ( + await asyncTest('exits 0 when counter file path is occupied by a directory', async () => { + const sessionId = `dirblock-${Date.now()}`; + const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); + // Create a DIRECTORY at the counter file path — openSync('a+') will fail with EISDIR + fs.mkdirSync(counterFile); - try { - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - CLAUDE_SESSION_ID: sessionId - }); - assert.strictEqual(result.code, 0, - 'Should exit 0 even when counter file path is a directory (graceful fallback)'); - } finally { - // Cleanup: remove the blocking directory - try { fs.rmdirSync(counterFile); } catch { /* best-effort */ } - } - })) passed++; else failed++; + try { + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + CLAUDE_SESSION_ID: sessionId + }); + assert.strictEqual(result.code, 0, 'Should exit 0 even when counter file path is a directory (graceful fallback)'); + } finally { + // Cleanup: remove the blocking directory + try { + fs.rmdirSync(counterFile); + } catch { + /* best-effort */ + } + } + }) + ) + passed++; + else failed++; // ── Round 59: session-start unreadable file, console-log stdin overflow, pre-compact write error ── console.log('\nRound 59: session-start.js (unreadable session file — readFile returns null):'); - if (await asyncTest('does not inject content when session file is unreadable', async () => { - // Skip on Windows or when running as root (permissions won't work) - if (process.platform === 'win32' || (process.getuid && process.getuid() === 0)) { - console.log(' (skipped — not supported on this platform)'); - return; - } - const isoHome = path.join(os.tmpdir(), `ecc-start-unreadable-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - // Create a session file with real content, then make it unreadable - const sessionFile = path.join(sessionsDir, `${Date.now()}-session.tmp`); - fs.writeFileSync(sessionFile, '# Sensitive session content that should NOT appear'); - fs.chmodSync(sessionFile, 0o000); - - try { - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 even with unreadable session file'); - // readFile returns null for unreadable files → content is null → no injection - assert.ok(!result.stdout.includes('Sensitive session content'), - 'Should NOT inject content from unreadable file'); - } finally { - try { fs.chmodSync(sessionFile, 0o644); } catch { /* best-effort */ } - try { fs.rmSync(isoHome, { recursive: true, force: true }); } catch { /* best-effort */ } - } - })) passed++; else failed++; + if ( + await asyncTest('does not inject content when session file is unreadable', async () => { + // Skip on Windows or when running as root (permissions won't work) + if (process.platform === 'win32' || (process.getuid && process.getuid() === 0)) { + console.log(' (skipped — not supported on this platform)'); + return; + } + const isoHome = path.join(os.tmpdir(), `ecc-start-unreadable-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create a session file with real content, then make it unreadable + const sessionFile = path.join(sessionsDir, `${Date.now()}-session.tmp`); + fs.writeFileSync(sessionFile, '# Sensitive session content that should NOT appear'); + fs.chmodSync(sessionFile, 0o000); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 even with unreadable session file'); + // readFile returns null for unreadable files → content is null → no injection + assert.ok(!result.stdout.includes('Sensitive session content'), 'Should NOT inject content from unreadable file'); + } finally { + try { + fs.chmodSync(sessionFile, 0o644); + } catch { + /* best-effort */ + } + try { + fs.rmSync(isoHome, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + } + }) + ) + passed++; + else failed++; console.log('\nRound 59: check-console-log.js (stdin exceeding 1MB — truncation):'); - if (await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit - const payload = 'x'.repeat(1024 * 1024 + 200000); - const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload); - - assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Output should be truncated — significantly less than input - assert.ok(result.stdout.length < payload.length, - `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Output should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, - `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); - })) passed++; else failed++; - - console.log('\nRound 59: pre-compact.js (read-only session file — appendFile error):'); + if ( + await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => { + // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit + const payload = 'x'.repeat(1024 * 1024 + 200000); + const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload); - if (await asyncTest('exits 0 when session file is read-only (appendFile fails)', async () => { - if (process.platform === 'win32' || (process.getuid && process.getuid() === 0)) { - console.log(' (skipped — not supported on this platform)'); - return; - } - const isoHome = path.join(os.tmpdir(), `ecc-compact-ro-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); + // Output should be truncated — significantly less than input + assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); + // Output should be approximately 1MB (last accepted chunk may push slightly over) + assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); + assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + }) + ) + passed++; + else failed++; - // Create a session file then make it read-only - const sessionFile = path.join(sessionsDir, `${Date.now()}-session.tmp`); - fs.writeFileSync(sessionFile, '# Active session\n'); - fs.chmodSync(sessionFile, 0o444); + console.log('\nRound 59: pre-compact.js (read-only session file — appendFile error):'); - try { - const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: isoHome, USERPROFILE: isoHome - }); - // Should exit 0 — hooks must not block the user (catch at lines 45-47) - assert.strictEqual(result.code, 0, 'Should exit 0 even when append fails'); - // Session file should remain unchanged (write was blocked) - const content = fs.readFileSync(sessionFile, 'utf8'); - assert.strictEqual(content, '# Active session\n', - 'Read-only session file should remain unchanged'); - } finally { - try { fs.chmodSync(sessionFile, 0o644); } catch { /* best-effort */ } - try { fs.rmSync(isoHome, { recursive: true, force: true }); } catch { /* best-effort */ } - } - })) passed++; else failed++; + if ( + await asyncTest('exits 0 when session file is read-only (appendFile fails)', async () => { + if (process.platform === 'win32' || (process.getuid && process.getuid() === 0)) { + console.log(' (skipped — not supported on this platform)'); + return; + } + const isoHome = path.join(os.tmpdir(), `ecc-compact-ro-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create a session file then make it read-only + const sessionFile = path.join(sessionsDir, `${Date.now()}-session.tmp`); + fs.writeFileSync(sessionFile, '# Active session\n'); + fs.chmodSync(sessionFile, 0o444); + + try { + const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }); + // Should exit 0 — hooks must not block the user (catch at lines 45-47) + assert.strictEqual(result.code, 0, 'Should exit 0 even when append fails'); + // Session file should remain unchanged (write was blocked) + const content = fs.readFileSync(sessionFile, 'utf8'); + assert.strictEqual(content, '# Active session\n', 'Read-only session file should remain unchanged'); + } finally { + try { + fs.chmodSync(sessionFile, 0o644); + } catch { + /* best-effort */ + } + try { + fs.rmSync(isoHome, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + } + }) + ) + passed++; + else failed++; // ── Round 60: replaceInFile failure, console-warn stdin overflow, format missing tool_input ── console.log('\nRound 60: session-end.js (replaceInFile returns false — timestamp update warning):'); - if (await asyncTest('logs warning when existing session file lacks Last Updated field', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-end-nots-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - // Create transcript with a user message so a summary is produced - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - fs.writeFileSync(transcriptPath, '{"type":"user","content":"test message"}\n'); - - // Pre-create session file WITHOUT the **Last Updated:** line - // Use today's date and a short ID matching getSessionIdShort() pattern - const today = new Date().toISOString().split('T')[0]; - const sessionFile = path.join(sessionsDir, `${today}-session-session.tmp`); - fs.writeFileSync(sessionFile, '# Session file without timestamp marker\nSome existing content\n'); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); + if ( + await asyncTest('logs warning when existing session file lacks Last Updated field', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-end-nots-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create transcript with a user message so a summary is produced + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, '{"type":"user","content":"test message"}\n'); - assert.strictEqual(result.code, 0, 'Should exit 0 even when replaceInFile fails'); - // replaceInFile returns false → line 166 logs warning about failed timestamp update - assert.ok(result.stderr.includes('Failed to update') || result.stderr.includes('[SessionEnd]'), - 'Should log warning when timestamp pattern not found in session file'); + // Pre-create session file WITHOUT the **Last Updated:** line + // Use today's date and a short ID matching getSessionIdShort() pattern + const today = new Date().toISOString().split('T')[0]; + const sessionFile = path.join(sessionsDir, `${today}-session-session.tmp`); + fs.writeFileSync(sessionFile, '# Session file without timestamp marker\nSome existing content\n'); - cleanupTestDir(testDir); - try { fs.rmSync(isoHome, { recursive: true, force: true }); } catch { /* best-effort */ } - })) passed++; else failed++; + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 even when replaceInFile fails'); + // replaceInFile returns false → line 166 logs warning about failed timestamp update + assert.ok(result.stderr.includes('Failed to update') || result.stderr.includes('[SessionEnd]'), 'Should log warning when timestamp pattern not found in session file'); + + cleanupTestDir(testDir); + try { + fs.rmSync(isoHome, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + }) + ) + passed++; + else failed++; console.log('\nRound 60: post-edit-console-warn.js (stdin exceeding 1MB — truncation):'); - if (await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit - const payload = 'x'.repeat(1024 * 1024 + 200000); - const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), payload); - - assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Data should be truncated — stdout significantly less than input - assert.ok(result.stdout.length < payload.length, - `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, - `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); - })) passed++; else failed++; + if ( + await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => { + // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit + const payload = 'x'.repeat(1024 * 1024 + 200000); + const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), payload); + + assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); + // Data should be truncated — stdout significantly less than input + assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); + // Should be approximately 1MB (last accepted chunk may push slightly over) + assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); + assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + }) + ) + passed++; + else failed++; console.log('\nRound 60: post-edit-format.js (valid JSON without tool_input key):'); - if (await asyncTest('skips formatting when JSON has no tool_input field', async () => { - const stdinJson = JSON.stringify({ result: 'ok', output: 'some data' }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); + if ( + await asyncTest('skips formatting when JSON has no tool_input field', async () => { + const stdinJson = JSON.stringify({ result: 'ok', output: 'some data' }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for JSON without tool_input'); - // input.tool_input?.file_path is undefined → skips formatting → passes through - assert.strictEqual(result.stdout, stdinJson, - 'Should pass through data unchanged when tool_input is absent'); - })) passed++; else failed++; + assert.strictEqual(result.code, 0, 'Should exit 0 for JSON without tool_input'); + // input.tool_input?.file_path is undefined → skips formatting → passes through + assert.strictEqual(result.stdout, stdinJson, 'Should pass through data unchanged when tool_input is absent'); + }) + ) + passed++; + else failed++; // ── Round 64: post-edit-typecheck.js valid JSON without tool_input ── console.log('\nRound 64: post-edit-typecheck.js (valid JSON without tool_input):'); - if (await asyncTest('skips typecheck when JSON has no tool_input field', async () => { - const stdinJson = JSON.stringify({ result: 'ok', metadata: { action: 'test' } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + if ( + await asyncTest('skips typecheck when JSON has no tool_input field', async () => { + const stdinJson = JSON.stringify({ result: 'ok', metadata: { action: 'test' } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - assert.strictEqual(result.code, 0, 'Should exit 0 for JSON without tool_input'); - // input.tool_input?.file_path is undefined → skips TS check → passes through - assert.strictEqual(result.stdout, stdinJson, - 'Should pass through data unchanged when tool_input is absent'); - })) passed++; else failed++; + assert.strictEqual(result.code, 0, 'Should exit 0 for JSON without tool_input'); + // input.tool_input?.file_path is undefined → skips TS check → passes through + assert.strictEqual(result.stdout, stdinJson, 'Should pass through data unchanged when tool_input is absent'); + }) + ) + passed++; + else failed++; // ── Round 66: session-end.js entry.role === 'user' fallback and nonexistent transcript ── console.log('\nRound 66: session-end.js (entry.role user fallback):'); - if (await asyncTest('extracts user messages from role-only format (no type field)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-role-only-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Use entries with ONLY role field (no type:"user") to exercise the fallback - const lines = [ - '{"role":"user","content":"Deploy the production build"}', - '{"role":"assistant","content":"I will deploy now"}', - '{"role":"user","content":"Check the logs after deploy"}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - // The role-only user messages should be extracted - assert.ok(content.includes('Deploy the production build') || content.includes('deploy'), - `Session file should include role-only user messages. Got: ${content.substring(0, 300)}`); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - cleanupTestDir(testDir); - } - })) passed++; else failed++; + if ( + await asyncTest('extracts user messages from role-only format (no type field)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-role-only-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Use entries with ONLY role field (no type:"user") to exercise the fallback + const lines = ['{"role":"user","content":"Deploy the production build"}', '{"role":"assistant","content":"I will deploy now"}', '{"role":"user","content":"Check the logs after deploy"}']; + fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + // The role-only user messages should be extracted + assert.ok(content.includes('Deploy the production build') || content.includes('deploy'), `Session file should include role-only user messages. Got: ${content.substring(0, 300)}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + cleanupTestDir(testDir); + } + }) + ) + passed++; + else failed++; console.log('\nRound 66: session-end.js (nonexistent transcript path):'); - if (await asyncTest('logs "Transcript not found" for nonexistent transcript_path', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-notfound-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + if ( + await asyncTest('logs "Transcript not found" for nonexistent transcript_path', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-notfound-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); - const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-99999.jsonl' }); + const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-99999.jsonl' }); - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0 for missing transcript'); - assert.ok( - result.stderr.includes('Transcript not found') || result.stderr.includes('not found'), - `Should log transcript not found. Got stderr: ${result.stderr.substring(0, 300)}` - ); - // Should still create a session file (with blank template, since summary is null) - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); - assert.ok(files.length > 0, 'Should still create session file even without transcript'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0 for missing transcript'); + assert.ok(result.stderr.includes('Transcript not found') || result.stderr.includes('not found'), `Should log transcript not found. Got stderr: ${result.stderr.substring(0, 300)}`); + // Should still create a session file (with blank template, since summary is null) + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); + assert.ok(files.length > 0, 'Should still create session file even without transcript'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 70: session-end.js entry.name / entry.input fallback in direct tool_use entries ── console.log('\nRound 70: session-end.js (entry.name/entry.input fallback):'); - if (await asyncTest('extracts tool name and file path from entry.name/entry.input (not tool_name/tool_input)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-r70-entryname-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - const transcriptPath = path.join(isoHome, 'transcript.jsonl'); - - // Use "name" and "input" fields instead of "tool_name" and "tool_input" - // This exercises the fallback at session-end.js lines 63 and 66: - // const toolName = entry.tool_name || entry.name || ''; - // const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; - const lines = [ - '{"type":"user","content":"Use the alt format fields"}', - '{"type":"tool_use","name":"Edit","input":{"file_path":"/src/alt-format.ts"}}', - '{"type":"tool_use","name":"Read","input":{"file_path":"/src/other.ts"}}', - '{"type":"tool_use","name":"Write","input":{"file_path":"/src/written.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - // Tools extracted via entry.name fallback - assert.ok(content.includes('Edit'), 'Should list Edit via entry.name fallback'); - assert.ok(content.includes('Read'), 'Should list Read via entry.name fallback'); - // Files modified via entry.input fallback (Edit and Write, not Read) - assert.ok(content.includes('/src/alt-format.ts'), 'Should list edited file via entry.input fallback'); - assert.ok(content.includes('/src/written.ts'), 'Should list written file via entry.input fallback'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('extracts tool name and file path from entry.name/entry.input (not tool_name/tool_input)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-r70-entryname-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + const transcriptPath = path.join(isoHome, 'transcript.jsonl'); + + // Use "name" and "input" fields instead of "tool_name" and "tool_input" + // This exercises the fallback at session-end.js lines 63 and 66: + // const toolName = entry.tool_name || entry.name || ''; + // const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; + const lines = [ + '{"type":"user","content":"Use the alt format fields"}', + '{"type":"tool_use","name":"Edit","input":{"file_path":"/src/alt-format.ts"}}', + '{"type":"tool_use","name":"Read","input":{"file_path":"/src/other.ts"}}', + '{"type":"tool_use","name":"Write","input":{"file_path":"/src/written.ts"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + // Tools extracted via entry.name fallback + assert.ok(content.includes('Edit'), 'Should list Edit via entry.name fallback'); + assert.ok(content.includes('Read'), 'Should list Read via entry.name fallback'); + // Files modified via entry.input fallback (Edit and Write, not Read) + assert.ok(content.includes('/src/alt-format.ts'), 'Should list edited file via entry.input fallback'); + assert.ok(content.includes('/src/written.ts'), 'Should list written file via entry.input fallback'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 71: session-start.js default source shows getSelectionPrompt ── console.log('\nRound 71: session-start.js (default source — selection prompt):'); - if (await asyncTest('shows selection prompt when no package manager preference found (default source)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-r71-ss-default-${Date.now()}`); - const isoProject = path.join(isoHome, 'project'); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - fs.mkdirSync(isoProject, { recursive: true }); - // No package.json, no lock files, no package-manager.json — forces default source - - try { - const result = await new Promise((resolve, reject) => { - const env = { ...process.env, HOME: isoHome, USERPROFILE: isoHome }; - delete env.CLAUDE_PACKAGE_MANAGER; // Remove any env-level PM override - const proc = spawn('node', [path.join(scriptsDir, 'session-start.js')], { - env, - cwd: isoProject, // CWD with no package.json or lock files - stdio: ['pipe', 'pipe', 'pipe'] + if ( + await asyncTest('shows selection prompt when no package manager preference found (default source)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-r71-ss-default-${Date.now()}`); + const isoProject = path.join(isoHome, 'project'); + fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + fs.mkdirSync(isoProject, { recursive: true }); + // No package.json, no lock files, no package-manager.json — forces default source + + try { + const result = await new Promise((resolve, reject) => { + const env = { ...process.env, HOME: isoHome, USERPROFILE: isoHome }; + delete env.CLAUDE_PACKAGE_MANAGER; // Remove any env-level PM override + const proc = spawn('node', [path.join(scriptsDir, 'session-start.js')], { + env, + cwd: isoProject, // CWD with no package.json or lock files + stdio: ['pipe', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', data => (stdout += data)); + proc.stderr.on('data', data => (stderr += data)); + proc.stdin.end(); + proc.on('close', code => resolve({ code, stdout, stderr })); + proc.on('error', reject); }); - let stdout = ''; - let stderr = ''; - proc.stdout.on('data', data => stdout += data); - proc.stderr.on('data', data => stderr += data); - proc.stdin.end(); - proc.on('close', code => resolve({ code, stdout, stderr })); - proc.on('error', reject); - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - assert.ok(result.stderr.includes('No package manager preference'), - `Should show selection prompt when source is default. Got stderr: ${result.stderr.slice(0, 500)}`); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stderr.includes('No package manager preference'), `Should show selection prompt when source is default. Got stderr: ${result.stderr.slice(0, 500)}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 74: session-start.js main().catch handler ── console.log('\nRound 74: session-start.js (main catch — unrecoverable error):'); - if (await asyncTest('session-start exits 0 with error message when HOME is non-directory', async () => { - if (process.platform === 'win32') { - console.log(' (skipped — /dev/null not available on Windows)'); - return; - } - // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR, - // which propagates to main().catch — the top-level error boundary - const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { - HOME: '/dev/null', - USERPROFILE: '/dev/null' - }); - assert.strictEqual(result.code, 0, - `Should exit 0 (don't block on errors), got ${result.code}`); - assert.ok(result.stderr.includes('[SessionStart] Error:'), - `stderr should contain [SessionStart] Error:, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + await asyncTest('session-start exits 0 with error message when HOME is non-directory', async () => { + if (process.platform === 'win32') { + console.log(' (skipped — /dev/null not available on Windows)'); + return; + } + // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR, + // which propagates to main().catch — the top-level error boundary + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: '/dev/null', + USERPROFILE: '/dev/null' + }); + assert.strictEqual(result.code, 0, `Should exit 0 (don't block on errors), got ${result.code}`); + assert.ok(result.stderr.includes('[SessionStart] Error:'), `stderr should contain [SessionStart] Error:, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; // ── Round 75: pre-compact.js main().catch handler ── console.log('\nRound 75: pre-compact.js (main catch — unrecoverable error):'); - if (await asyncTest('pre-compact exits 0 with error message when HOME is non-directory', async () => { - if (process.platform === 'win32') { - console.log(' (skipped — /dev/null not available on Windows)'); - return; - } - // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR, - // which propagates to main().catch — the top-level error boundary - const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { - HOME: '/dev/null', - USERPROFILE: '/dev/null' - }); - assert.strictEqual(result.code, 0, - `Should exit 0 (don't block on errors), got ${result.code}`); - assert.ok(result.stderr.includes('[PreCompact] Error:'), - `stderr should contain [PreCompact] Error:, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + await asyncTest('pre-compact exits 0 with error message when HOME is non-directory', async () => { + if (process.platform === 'win32') { + console.log(' (skipped — /dev/null not available on Windows)'); + return; + } + // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR, + // which propagates to main().catch — the top-level error boundary + const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), '', { + HOME: '/dev/null', + USERPROFILE: '/dev/null' + }); + assert.strictEqual(result.code, 0, `Should exit 0 (don't block on errors), got ${result.code}`); + assert.ok(result.stderr.includes('[PreCompact] Error:'), `stderr should contain [PreCompact] Error:, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; // ── Round 75: session-end.js main().catch handler ── console.log('\nRound 75: session-end.js (main catch — unrecoverable error):'); - if (await asyncTest('session-end exits 0 with error message when HOME is non-directory', async () => { - if (process.platform === 'win32') { - console.log(' (skipped — /dev/null not available on Windows)'); - return; - } - // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR inside main(), - // which propagates to runMain().catch — the top-level error boundary - const result = await runScript(path.join(scriptsDir, 'session-end.js'), '{}', { - HOME: '/dev/null', - USERPROFILE: '/dev/null' - }); - assert.strictEqual(result.code, 0, - `Should exit 0 (don't block on errors), got ${result.code}`); - assert.ok(result.stderr.includes('[SessionEnd] Error:'), - `stderr should contain [SessionEnd] Error:, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + await asyncTest('session-end exits 0 with error message when HOME is non-directory', async () => { + if (process.platform === 'win32') { + console.log(' (skipped — /dev/null not available on Windows)'); + return; + } + // HOME=/dev/null makes ensureDir(sessionsDir) throw ENOTDIR inside main(), + // which propagates to runMain().catch — the top-level error boundary + const result = await runScript(path.join(scriptsDir, 'session-end.js'), '{}', { + HOME: '/dev/null', + USERPROFILE: '/dev/null' + }); + assert.strictEqual(result.code, 0, `Should exit 0 (don't block on errors), got ${result.code}`); + assert.ok(result.stderr.includes('[SessionEnd] Error:'), `stderr should contain [SessionEnd] Error:, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; // ── Round 76: evaluate-session.js main().catch handler ── console.log('\nRound 76: evaluate-session.js (main catch — unrecoverable error):'); - if (await asyncTest('evaluate-session exits 0 with error message when HOME is non-directory', async () => { - if (process.platform === 'win32') { - console.log(' (skipped — /dev/null not available on Windows)'); - return; - } - // HOME=/dev/null makes ensureDir(learnedSkillsPath) throw ENOTDIR, - // which propagates to main().catch — the top-level error boundary - const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), '{}', { - HOME: '/dev/null', - USERPROFILE: '/dev/null' - }); - assert.strictEqual(result.code, 0, - `Should exit 0 (don't block on errors), got ${result.code}`); - assert.ok(result.stderr.includes('[ContinuousLearning] Error:'), - `stderr should contain [ContinuousLearning] Error:, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + await asyncTest('evaluate-session exits 0 with error message when HOME is non-directory', async () => { + if (process.platform === 'win32') { + console.log(' (skipped — /dev/null not available on Windows)'); + return; + } + // HOME=/dev/null makes ensureDir(learnedSkillsPath) throw ENOTDIR, + // which propagates to main().catch — the top-level error boundary + const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), '{}', { + HOME: '/dev/null', + USERPROFILE: '/dev/null' + }); + assert.strictEqual(result.code, 0, `Should exit 0 (don't block on errors), got ${result.code}`); + assert.ok(result.stderr.includes('[ContinuousLearning] Error:'), `stderr should contain [ContinuousLearning] Error:, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; // ── Round 76: suggest-compact.js main().catch handler ── console.log('\nRound 76: suggest-compact.js (main catch — double-failure):'); - if (await asyncTest('suggest-compact exits 0 with error when TMPDIR is non-directory', async () => { - if (process.platform === 'win32') { - console.log(' (skipped — /dev/null not available on Windows)'); - return; - } - // TMPDIR=/dev/null causes openSync to fail (ENOTDIR), then the catch - // fallback writeFile also fails, propagating to main().catch - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - TMPDIR: '/dev/null' - }); - assert.strictEqual(result.code, 0, - `Should exit 0 (don't block on errors), got ${result.code}`); - assert.ok(result.stderr.includes('[StrategicCompact] Error:'), - `stderr should contain [StrategicCompact] Error:, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + await asyncTest('suggest-compact exits 0 with error when TMPDIR is non-directory', async () => { + if (process.platform === 'win32') { + console.log(' (skipped — /dev/null not available on Windows)'); + return; + } + // TMPDIR=/dev/null causes openSync to fail (ENOTDIR), then the catch + // fallback writeFile also fails, propagating to main().catch + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + TMPDIR: '/dev/null' + }); + assert.strictEqual(result.code, 0, `Should exit 0 (don't block on errors), got ${result.code}`); + assert.ok(result.stderr.includes('[StrategicCompact] Error:'), `stderr should contain [StrategicCompact] Error:, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; // ── Round 80: session-end.js entry.message?.role === 'user' third OR condition ── console.log('\nRound 80: session-end.js (entry.message.role user — third OR condition):'); - if (await asyncTest('extracts user messages from entries where only message.role is user (not type or role)', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-msgrole-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Entries where type is NOT 'user' and there is no direct role field, - // but message.role IS 'user'. This exercises the third OR condition at - // session-end.js line 48: entry.message?.role === 'user' - const lines = [ - '{"type":"human","message":{"role":"user","content":"Refactor the auth module"}}', - '{"type":"human","message":{"role":"assistant","content":"I will refactor it"}}', - '{"type":"human","message":{"role":"user","content":"Add integration tests too"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0); - - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - // The third OR condition should fire for type:"human" + message.role:"user" - assert.ok(content.includes('Refactor the auth module') || content.includes('auth'), - `Session should include message extracted via message.role path. Got: ${content.substring(0, 300)}`); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - cleanupTestDir(testDir); - } - })) passed++; else failed++; + if ( + await asyncTest('extracts user messages from entries where only message.role is user (not type or role)', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-msgrole-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + // Entries where type is NOT 'user' and there is no direct role field, + // but message.role IS 'user'. This exercises the third OR condition at + // session-end.js line 48: entry.message?.role === 'user' + const lines = [ + '{"type":"human","message":{"role":"user","content":"Refactor the auth module"}}', + '{"type":"human","message":{"role":"assistant","content":"I will refactor it"}}', + '{"type":"human","message":{"role":"user","content":"Add integration tests too"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('-session.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + // The third OR condition should fire for type:"human" + message.role:"user" + assert.ok(content.includes('Refactor the auth module') || content.includes('auth'), `Session should include message extracted via message.role path. Got: ${content.substring(0, 300)}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + cleanupTestDir(testDir); + } + }) + ) + passed++; + else failed++; // ── Round 81: suggest-compact threshold upper bound, session-end non-string content ── console.log('\nRound 81: suggest-compact.js (COMPACT_THRESHOLD > 10000):'); - if (await asyncTest('COMPACT_THRESHOLD exceeding 10000 falls back to default 50', async () => { - // suggest-compact.js line 31: rawThreshold <= 10000 ? rawThreshold : 50 - // Values > 10000 are positive and finite but fail the upper-bound check. - // Existing tests cover 0, negative, NaN — this covers the > 10000 boundary. - const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { - COMPACT_THRESHOLD: '20000' - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - // The script logs the threshold it chose — should fall back to 50 - // Look for the fallback value in stderr (log output) - const compactSource = fs.readFileSync(path.join(scriptsDir, 'suggest-compact.js'), 'utf8'); - // The condition at line 31: rawThreshold <= 10000 ? rawThreshold : 50 - assert.ok(compactSource.includes('<= 10000'), - 'Source should have <= 10000 upper bound check'); - assert.ok(compactSource.includes(': 50'), - 'Source should fall back to 50 when threshold exceeds 10000'); - })) passed++; else failed++; - - console.log('\nRound 81: session-end.js (user entry with non-string non-array content):'); - - if (await asyncTest('skips user messages with numeric content (non-string non-array branch)', async () => { - // session-end.js line 50-55: rawContent is checked for string, then array, else '' - // When content is a number (42), neither branch matches, text = '', message is skipped. - const isoHome = path.join(os.tmpdir(), `ecc-r81-numcontent-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - const transcriptPath = path.join(isoHome, 'transcript.jsonl'); - - const lines = [ - // Normal user message (string content) — should be included - '{"type":"user","content":"Real user message"}', - // User message with numeric content — exercises the else: '' branch - '{"type":"user","content":42}', - // User message with boolean content — also hits the else branch - '{"type":"user","content":true}', - // User message with object content (no .text) — also hits the else branch - '{"type":"user","content":{"type":"image","source":"data:..."}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome + if ( + await asyncTest('COMPACT_THRESHOLD exceeding 10000 falls back to default 50', async () => { + // suggest-compact.js line 31: rawThreshold <= 10000 ? rawThreshold : 50 + // Values > 10000 are positive and finite but fail the upper-bound check. + // Existing tests cover 0, negative, NaN — this covers the > 10000 boundary. + const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { + COMPACT_THRESHOLD: '20000' }); assert.strictEqual(result.code, 0, 'Should exit 0'); + // The script logs the threshold it chose — should fall back to 50 + // Look for the fallback value in stderr (log output) + const compactSource = fs.readFileSync(path.join(scriptsDir, 'suggest-compact.js'), 'utf8'); + // The condition at line 31: rawThreshold <= 10000 ? rawThreshold : 50 + assert.ok(compactSource.includes('<= 10000'), 'Source should have <= 10000 upper bound check'); + assert.ok(compactSource.includes(': 50'), 'Source should fall back to 50 when threshold exceeds 10000'); + }) + ) + passed++; + else failed++; - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - // The real string message should appear - assert.ok(content.includes('Real user message'), - 'Should include the string content user message'); - // Numeric/boolean/object content should NOT appear as text - assert.ok(!content.includes('42'), - 'Numeric content should be skipped (else branch → empty string → filtered)'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + console.log('\nRound 81: session-end.js (user entry with non-string non-array content):'); + + if ( + await asyncTest('skips user messages with numeric content (non-string non-array branch)', async () => { + // session-end.js line 50-55: rawContent is checked for string, then array, else '' + // When content is a number (42), neither branch matches, text = '', message is skipped. + const isoHome = path.join(os.tmpdir(), `ecc-r81-numcontent-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + const transcriptPath = path.join(isoHome, 'transcript.jsonl'); + + const lines = [ + // Normal user message (string content) — should be included + '{"type":"user","content":"Real user message"}', + // User message with numeric content — exercises the else: '' branch + '{"type":"user","content":42}', + // User message with boolean content — also hits the else branch + '{"type":"user","content":true}', + // User message with object content (no .text) — also hits the else branch + '{"type":"user","content":{"type":"image","source":"data:..."}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + // The real string message should appear + assert.ok(content.includes('Real user message'), 'Should include the string content user message'); + // Numeric/boolean/object content should NOT appear as text + assert.ok(!content.includes('42'), 'Numeric content should be skipped (else branch → empty string → filtered)'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 82: tool_name OR fallback, template marker regex no-match ── console.log('\nRound 82: session-end.js (entry.tool_name without type=tool_use):'); - if (await asyncTest('collects tool name from entry with tool_name but non-tool_use type', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-r82-toolname-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - - const transcriptPath = path.join(isoHome, 'transcript.jsonl'); - const lines = [ - '{"type":"user","content":"Fix the bug"}', - '{"type":"result","tool_name":"Edit","tool_input":{"file_path":"/tmp/app.js"}}', - '{"type":"assistant","message":{"content":[{"type":"text","text":"Done fixing"}]}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); - assert.ok(files.length > 0, 'Should create session file'); - const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); - // The tool name "Edit" should appear even though type is "result", not "tool_use" - assert.ok(content.includes('Edit'), 'Should collect Edit tool via tool_name OR fallback'); - // The file modified should also be collected since tool_name is Edit - assert.ok(content.includes('app.js'), 'Should collect modified file path from tool_input'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + if ( + await asyncTest('collects tool name from entry with tool_name but non-tool_use type', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-r82-toolname-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const transcriptPath = path.join(isoHome, 'transcript.jsonl'); + const lines = [ + '{"type":"user","content":"Fix the bug"}', + '{"type":"result","tool_name":"Edit","tool_input":{"file_path":"/tmp/app.js"}}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"Done fixing"}]}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + // The tool name "Edit" should appear even though type is "result", not "tool_use" + assert.ok(content.includes('Edit'), 'Should collect Edit tool via tool_name OR fallback'); + // The file modified should also be collected since tool_name is Edit + assert.ok(content.includes('app.js'), 'Should collect modified file path from tool_input'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; console.log('\nRound 82: session-end.js (template marker present but regex no-match):'); - if (await asyncTest('preserves file when marker present but regex does not match corrupted template', async () => { - const isoHome = path.join(os.tmpdir(), `ecc-r82-tmpl-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); + if ( + await asyncTest('preserves file when marker present but regex does not match corrupted template', async () => { + const isoHome = path.join(os.tmpdir(), `ecc-r82-tmpl-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); - const today = new Date().toISOString().split('T')[0]; - const sessionFile = path.join(sessionsDir, `session-${today}.tmp`); + const today = new Date().toISOString().split('T')[0]; + const sessionFile = path.join(sessionsDir, `session-${today}.tmp`); - // Write a corrupted template: has the marker but NOT the full regex structure - const corruptedTemplate = `# Session: ${today} + // Write a corrupted template: has the marker but NOT the full regex structure + const corruptedTemplate = `# Session: ${today} **Date:** ${today} **Started:** 10:00 **Last Updated:** 10:00 @@ -3422,255 +4085,283 @@ async function runTests() { Some random content without the expected ### Context to Load section `; - fs.writeFileSync(sessionFile, corruptedTemplate); - - // Provide a transcript with enough content to generate a summary - const transcriptPath = path.join(isoHome, 'transcript.jsonl'); - const lines = [ - '{"type":"user","content":"Implement authentication feature"}', - '{"type":"assistant","message":{"content":[{"type":"text","text":"I will implement the auth feature using JWT tokens and bcrypt for password hashing."}]}}', - '{"type":"tool_use","tool_name":"Write","name":"Write","tool_input":{"file_path":"/tmp/auth.js"}}', - '{"type":"user","content":"Now add the login endpoint"}', - '{"type":"assistant","message":{"content":[{"type":"text","text":"Adding the login endpoint with proper validation."}]}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - try { - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: isoHome, USERPROFILE: isoHome - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - - const content = fs.readFileSync(sessionFile, 'utf8'); - // The marker text should still be present since regex didn't match - assert.ok(content.includes('[Session context goes here]'), - 'Marker should remain when regex fails to match corrupted template'); - // The corrupted content should still be there - assert.ok(content.includes('Some random content'), - 'Original corrupted content should be preserved'); - } finally { - fs.rmSync(isoHome, { recursive: true, force: true }); - } - })) passed++; else failed++; + fs.writeFileSync(sessionFile, corruptedTemplate); + + // Provide a transcript with enough content to generate a summary + const transcriptPath = path.join(isoHome, 'transcript.jsonl'); + const lines = [ + '{"type":"user","content":"Implement authentication feature"}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"I will implement the auth feature using JWT tokens and bcrypt for password hashing."}]}}', + '{"type":"tool_use","tool_name":"Write","name":"Write","tool_input":{"file_path":"/tmp/auth.js"}}', + '{"type":"user","content":"Now add the login endpoint"}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"Adding the login endpoint with proper validation."}]}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + try { + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: isoHome, + USERPROFILE: isoHome + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + + const content = fs.readFileSync(sessionFile, 'utf8'); + // The marker text should still be present since regex didn't match + assert.ok(content.includes('[Session context goes here]'), 'Marker should remain when regex fails to match corrupted template'); + // The corrupted content should still be there + assert.ok(content.includes('Some random content'), 'Original corrupted content should be preserved'); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; // ── Round 87: post-edit-format.js and post-edit-typecheck.js stdin overflow (1MB) ── console.log('\nRound 87: post-edit-format.js (stdin exceeding 1MB — truncation):'); - if (await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-format)', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 14-22) - const payload = 'x'.repeat(1024 * 1024 + 200000); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), payload); - - assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Output should be truncated — significantly less than input - assert.ok(result.stdout.length < payload.length, - `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Output should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, - `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); - })) passed++; else failed++; + if ( + await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-format)', async () => { + // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 14-22) + const payload = 'x'.repeat(1024 * 1024 + 200000); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), payload); + + assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); + // Output should be truncated — significantly less than input + assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); + // Output should be approximately 1MB (last accepted chunk may push slightly over) + assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); + assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + }) + ) + passed++; + else failed++; console.log('\nRound 87: post-edit-typecheck.js (stdin exceeding 1MB — truncation):'); - if (await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-typecheck)', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 16-24) - const payload = 'x'.repeat(1024 * 1024 + 200000); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), payload); - - assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Output should be truncated — significantly less than input - assert.ok(result.stdout.length < payload.length, - `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Output should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, - `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); - })) passed++; else failed++; + if ( + await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-typecheck)', async () => { + // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 16-24) + const payload = 'x'.repeat(1024 * 1024 + 200000); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), payload); + + assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); + // Output should be truncated — significantly less than input + assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); + // Output should be approximately 1MB (last accepted chunk may push slightly over) + assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); + assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + }) + ) + passed++; + else failed++; // ── Round 89: post-edit-typecheck.js error detection path (relevantLines) ── console.log('\nRound 89: post-edit-typecheck.js (TypeScript error detection path):'); - if (await asyncTest('filters TypeScript errors to edited file when tsc reports errors', async () => { - // post-edit-typecheck.js lines 60-85: when execFileSync('npx', ['tsc', ...]) throws, - // the catch block filters error output by file path candidates and logs relevant lines. - // All existing tests either have no tsconfig (tsc never runs) or valid TS (tsc succeeds). - // This test creates a .ts file with a type error and a tsconfig.json. - const testDir = createTestDir(); - fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - const testFile = path.join(testDir, 'broken.ts'); - // Intentional type error: assigning string to number - fs.writeFileSync(testFile, 'const x: number = "not a number";\n'); - - const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); - - // Core: script must exit 0 and pass through stdin data regardless - assert.strictEqual(result.code, 0, 'Should exit 0 even when tsc finds errors'); - const parsed = JSON.parse(result.stdout); - assert.strictEqual(parsed.tool_input.file_path, testFile, - 'Should pass through original stdin data with file_path intact'); - - // If tsc is available and ran, check that error output is filtered to this file - if (result.stderr.includes('TypeScript errors in')) { - assert.ok(result.stderr.includes('broken.ts'), - `Should reference the edited file basename. Got: ${result.stderr}`); - } - // Either way, no crash and data passes through (verified above) - cleanupTestDir(testDir); - })) passed++; else failed++; + if ( + await asyncTest('filters TypeScript errors to edited file when tsc reports errors', async () => { + // post-edit-typecheck.js lines 60-85: when execFileSync('npx', ['tsc', ...]) throws, + // the catch block filters error output by file path candidates and logs relevant lines. + // All existing tests either have no tsconfig (tsc never runs) or valid TS (tsc succeeds). + // This test creates a .ts file with a type error and a tsconfig.json. + const testDir = createTestDir(); + fs.writeFileSync( + path.join(testDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true } + }) + ); + const testFile = path.join(testDir, 'broken.ts'); + // Intentional type error: assigning string to number + fs.writeFileSync(testFile, 'const x: number = "not a number";\n'); + + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); + + // Core: script must exit 0 and pass through stdin data regardless + assert.strictEqual(result.code, 0, 'Should exit 0 even when tsc finds errors'); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.tool_input.file_path, testFile, 'Should pass through original stdin data with file_path intact'); + + // If tsc is available and ran, check that error output is filtered to this file + if (result.stderr.includes('TypeScript errors in')) { + assert.ok(result.stderr.includes('broken.ts'), `Should reference the edited file basename. Got: ${result.stderr}`); + } + // Either way, no crash and data passes through (verified above) + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ── Round 89: extractSessionSummary entry.name + entry.input fallback paths ── console.log('\nRound 89: session-end.js (entry.name + entry.input fallback in extractSessionSummary):'); - if (await asyncTest('extracts tool name from entry.name and file path from entry.input (fallback format)', async () => { - // session-end.js line 63: const toolName = entry.tool_name || entry.name || ''; - // session-end.js line 66: const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; - // All existing tests use tool_name + tool_input format. This tests the name + input fallback. - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Fix the auth module"}', - // Tool entries using "name" + "input" instead of "tool_name" + "tool_input" - '{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth.ts"}}', - '{"type":"tool_use","name":"Write","input":{"file_path":"/src/new-helper.ts"}}', - // Also include a tool with tool_name but entry.input (mixed format) - '{"tool_name":"Read","input":{"file_path":"/src/config.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - - // Read the session file to verify tool names and file paths were extracted - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - // Tools from entry.name fallback - assert.ok(content.includes('Edit'), - `Should extract Edit tool from entry.name fallback. Got: ${content}`); - assert.ok(content.includes('Write'), - `Should extract Write tool from entry.name fallback. Got: ${content}`); - // File paths from entry.input fallback - assert.ok(content.includes('/src/auth.ts'), - `Should extract file path from entry.input.file_path fallback. Got: ${content}`); - assert.ok(content.includes('/src/new-helper.ts'), - `Should extract Write file from entry.input.file_path fallback. Got: ${content}`); + if ( + await asyncTest('extracts tool name from entry.name and file path from entry.input (fallback format)', async () => { + // session-end.js line 63: const toolName = entry.tool_name || entry.name || ''; + // session-end.js line 66: const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; + // All existing tests use tool_name + tool_input format. This tests the name + input fallback. + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = [ + '{"type":"user","content":"Fix the auth module"}', + // Tool entries using "name" + "input" instead of "tool_name" + "tool_input" + '{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth.ts"}}', + '{"type":"tool_use","name":"Write","input":{"file_path":"/src/new-helper.ts"}}', + // Also include a tool with tool_name but entry.input (mixed format) + '{"tool_name":"Read","input":{"file_path":"/src/config.ts"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + + // Read the session file to verify tool names and file paths were extracted + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + // Tools from entry.name fallback + assert.ok(content.includes('Edit'), `Should extract Edit tool from entry.name fallback. Got: ${content}`); + assert.ok(content.includes('Write'), `Should extract Write tool from entry.name fallback. Got: ${content}`); + // File paths from entry.input fallback + assert.ok(content.includes('/src/auth.ts'), `Should extract file path from entry.input.file_path fallback. Got: ${content}`); + assert.ok(content.includes('/src/new-helper.ts'), `Should extract Write file from entry.input.file_path fallback. Got: ${content}`); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // ── Round 90: readStdinJson timeout path (utils.js lines 215-229) ── console.log('\nRound 90: readStdinJson (timeout fires when stdin stays open):'); - if (await asyncTest('readStdinJson resolves with {} when stdin never closes (timeout fires, no data)', async () => { - // utils.js line 215: setTimeout fires because stdin 'end' never arrives. - // Line 225: data.trim() is empty → resolves with {}. - // Exercises: removeAllListeners, process.stdin.unref(), and the empty-data timeout resolution. - const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; - return new Promise((resolve, reject) => { - const child = spawn('node', ['-e', script], { - cwd: path.resolve(__dirname, '..', '..'), - stdio: ['pipe', 'pipe', 'pipe'] - }); - // Don't write anything or close stdin — force the timeout to fire - let stdout = ''; - child.stdout.on('data', d => stdout += d); - const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000); - child.on('close', (code) => { - clearTimeout(timer); - try { - assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); - const parsed = JSON.parse(stdout); - assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when no data received before timeout'); - resolve(); - } catch (err) { - reject(err); - } - }); - }); - })) passed++; else failed++; - - if (await asyncTest('readStdinJson resolves with {} when timeout fires with invalid partial JSON', async () => { - // utils.js lines 224-228: setTimeout fires, data.trim() is non-empty, - // JSON.parse(data) throws → catch at line 226 resolves with {}. - const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; - return new Promise((resolve, reject) => { - const child = spawn('node', ['-e', script], { - cwd: path.resolve(__dirname, '..', '..'), - stdio: ['pipe', 'pipe', 'pipe'] + if ( + await asyncTest('readStdinJson resolves with {} when stdin never closes (timeout fires, no data)', async () => { + // utils.js line 215: setTimeout fires because stdin 'end' never arrives. + // Line 225: data.trim() is empty → resolves with {}. + // Exercises: removeAllListeners, process.stdin.unref(), and the empty-data timeout resolution. + const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; + return new Promise((resolve, reject) => { + const child = spawn('node', ['-e', script], { + cwd: path.resolve(__dirname, '..', '..'), + stdio: ['pipe', 'pipe', 'pipe'] + }); + // Don't write anything or close stdin — force the timeout to fire + let stdout = ''; + child.stdout.on('data', d => (stdout += d)); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Test timed out')); + }, 5000); + child.on('close', code => { + clearTimeout(timer); + try { + assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); + const parsed = JSON.parse(stdout); + assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when no data received before timeout'); + resolve(); + } catch (err) { + reject(err); + } + }); }); - // Write partial invalid JSON but don't close stdin — timeout fires with unparseable data - child.stdin.write('{"incomplete":'); - let stdout = ''; - child.stdout.on('data', d => stdout += d); - const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000); - child.on('close', (code) => { - clearTimeout(timer); - try { - assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); - const parsed = JSON.parse(stdout); - assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when partial JSON cannot be parsed'); - resolve(); - } catch (err) { - reject(err); - } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('readStdinJson resolves with {} when timeout fires with invalid partial JSON', async () => { + // utils.js lines 224-228: setTimeout fires, data.trim() is non-empty, + // JSON.parse(data) throws → catch at line 226 resolves with {}. + const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; + return new Promise((resolve, reject) => { + const child = spawn('node', ['-e', script], { + cwd: path.resolve(__dirname, '..', '..'), + stdio: ['pipe', 'pipe', 'pipe'] + }); + // Write partial invalid JSON but don't close stdin — timeout fires with unparseable data + child.stdin.write('{"incomplete":'); + let stdout = ''; + child.stdout.on('data', d => (stdout += d)); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Test timed out')); + }, 5000); + child.on('close', code => { + clearTimeout(timer); + try { + assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); + const parsed = JSON.parse(stdout); + assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when partial JSON cannot be parsed'); + resolve(); + } catch (err) { + reject(err); + } + }); }); - }); - })) passed++; else failed++; + }) + ) + passed++; + else failed++; // ── Round 94: session-end.js tools used but no files modified ── console.log('\nRound 94: session-end.js (tools used without files modified):'); - if (await asyncTest('session file includes Tools Used but omits Files Modified when only Read/Grep used', async () => { - // session-end.js buildSummarySection (lines 217-228): - // filesModified.length > 0 → include "### Files Modified" section - // toolsUsed.length > 0 → include "### Tools Used" section - // Previously tested: BOTH present (Round ~10) and NEITHER present (Round ~10). - // Untested combination: toolsUsed present, filesModified empty. - // Transcript with Read/Grep tools (don't add to filesModified) and user messages. - const testDir = createTestDir(); - const transcriptPath = path.join(testDir, 'transcript.jsonl'); - - const lines = [ - '{"type":"user","content":"Search the codebase for auth handlers"}', - '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/auth.ts"}}', - '{"type":"tool_use","tool_name":"Grep","tool_input":{"pattern":"handler"}}', - '{"type":"user","content":"Check the test file too"}', - '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/tests/auth.test.ts"}}', - ]; - fs.writeFileSync(transcriptPath, lines.join('\n')); - - const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); - const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { - HOME: testDir - }); - assert.strictEqual(result.code, 0, 'Should exit 0'); - - const claudeDir = path.join(testDir, '.claude', 'sessions'); - if (fs.existsSync(claudeDir)) { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); - if (files.length > 0) { - const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); - assert.ok(content.includes('### Tools Used'), 'Should include Tools Used section'); - assert.ok(content.includes('Read'), 'Should list Read tool'); - assert.ok(content.includes('Grep'), 'Should list Grep tool'); - assert.ok(!content.includes('### Files Modified'), - 'Should NOT include Files Modified section (Read/Grep do not modify files)'); + if ( + await asyncTest('session file includes Tools Used but omits Files Modified when only Read/Grep used', async () => { + // session-end.js buildSummarySection (lines 217-228): + // filesModified.length > 0 → include "### Files Modified" section + // toolsUsed.length > 0 → include "### Tools Used" section + // Previously tested: BOTH present (Round ~10) and NEITHER present (Round ~10). + // Untested combination: toolsUsed present, filesModified empty. + // Transcript with Read/Grep tools (don't add to filesModified) and user messages. + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + const lines = [ + '{"type":"user","content":"Search the codebase for auth handlers"}', + '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/auth.ts"}}', + '{"type":"tool_use","tool_name":"Grep","tool_input":{"pattern":"handler"}}', + '{"type":"user","content":"Check the test file too"}', + '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/tests/auth.test.ts"}}' + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir + }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + + const claudeDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(claudeDir)) { + const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); + if (files.length > 0) { + const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); + assert.ok(content.includes('### Tools Used'), 'Should include Tools Used section'); + assert.ok(content.includes('Read'), 'Should list Read tool'); + assert.ok(content.includes('Grep'), 'Should list Grep tool'); + assert.ok(!content.includes('### Files Modified'), 'Should NOT include Files Modified section (Read/Grep do not modify files)'); + } } - } - cleanupTestDir(testDir); - })) passed++; else failed++; + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; // Summary console.log('\n=== Test Results ==='); diff --git a/tests/lib/resolve-formatter.test.js b/tests/lib/resolve-formatter.test.js new file mode 100644 index 000000000..c02bb60db --- /dev/null +++ b/tests/lib/resolve-formatter.test.js @@ -0,0 +1,246 @@ +/** + * Tests for scripts/lib/resolve-formatter.js + * + * Run with: node tests/lib/resolve-formatter.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { findProjectRoot, detectFormatter, resolveFormatterBin, clearCaches } = require('../../scripts/lib/resolve-formatter'); + +/** + * Run a single test case, printing pass/fail. + * + * @param {string} name - Test description + * @param {() => void} fn - Test body (throws on failure) + * @returns {boolean} Whether the test passed + */ +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +/** Track all created tmp dirs for cleanup */ +const tmpDirs = []; + +/** + * Create a temporary directory and track it for cleanup. + * + * @returns {string} Absolute path to the new temp directory + */ +function makeTmpDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-fmt-')); + tmpDirs.push(dir); + return dir; +} + +/** + * Remove all tracked temporary directories. + */ +function cleanupTmpDirs() { + for (const dir of tmpDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + } + tmpDirs.length = 0; +} + +function runTests() { + console.log('\n=== Testing resolve-formatter.js ===\n'); + + let passed = 0; + let failed = 0; + + function run(name, fn) { + clearCaches(); + if (test(name, fn)) passed++; + else failed++; + } + + // ── findProjectRoot ─────────────────────────────────────────── + + run('findProjectRoot: finds package.json in parent dir', () => { + const root = makeTmpDir(); + const sub = path.join(root, 'src', 'lib'); + fs.mkdirSync(sub, { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{}'); + + assert.strictEqual(findProjectRoot(sub), root); + }); + + run('findProjectRoot: returns startDir when no package.json', () => { + const root = makeTmpDir(); + const sub = path.join(root, 'deep'); + fs.mkdirSync(sub, { recursive: true }); + + // No package.json anywhere in tmp → falls back to startDir + assert.strictEqual(findProjectRoot(sub), sub); + }); + + run('findProjectRoot: caches result for same startDir', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'package.json'), '{}'); + + const first = findProjectRoot(root); + // Remove package.json — cache should still return the old result + fs.unlinkSync(path.join(root, 'package.json')); + const second = findProjectRoot(root); + + assert.strictEqual(first, second); + }); + + // ── detectFormatter ─────────────────────────────────────────── + + run('detectFormatter: detects biome.json', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'biome.json'), '{}'); + assert.strictEqual(detectFormatter(root), 'biome'); + }); + + run('detectFormatter: detects biome.jsonc', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'biome.jsonc'), '{}'); + assert.strictEqual(detectFormatter(root), 'biome'); + }); + + run('detectFormatter: detects .prettierrc', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, '.prettierrc'), '{}'); + assert.strictEqual(detectFormatter(root), 'prettier'); + }); + + run('detectFormatter: detects prettier.config.js', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'prettier.config.js'), 'module.exports = {}'); + assert.strictEqual(detectFormatter(root), 'prettier'); + }); + + 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 } })); + assert.strictEqual(detectFormatter(root), 'prettier'); + }); + + run('detectFormatter: ignores package.json without prettier key', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test' })); + assert.strictEqual(detectFormatter(root), null); + }); + + run('detectFormatter: biome takes priority over prettier', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'biome.json'), '{}'); + fs.writeFileSync(path.join(root, '.prettierrc'), '{}'); + assert.strictEqual(detectFormatter(root), 'biome'); + }); + + run('detectFormatter: returns null when no config found', () => { + const root = makeTmpDir(); + assert.strictEqual(detectFormatter(root), null); + }); + + // ── resolveFormatterBin ─────────────────────────────────────── + + run('resolveFormatterBin: uses local biome binary when available', () => { + const root = makeTmpDir(); + const binDir = path.join(root, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const binName = process.platform === 'win32' ? 'biome.cmd' : 'biome'; + fs.writeFileSync(path.join(binDir, binName), ''); + + const result = resolveFormatterBin(root, 'biome'); + assert.strictEqual(result.bin, path.join(binDir, binName)); + assert.deepStrictEqual(result.prefix, []); + }); + + run('resolveFormatterBin: falls back to npx for biome', () => { + const root = makeTmpDir(); + const result = resolveFormatterBin(root, 'biome'); + const expectedBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + assert.strictEqual(result.bin, expectedBin); + assert.deepStrictEqual(result.prefix, ['@biomejs/biome']); + }); + + run('resolveFormatterBin: uses local prettier binary when available', () => { + const root = makeTmpDir(); + const binDir = path.join(root, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const binName = process.platform === 'win32' ? 'prettier.cmd' : 'prettier'; + fs.writeFileSync(path.join(binDir, binName), ''); + + const result = resolveFormatterBin(root, 'prettier'); + assert.strictEqual(result.bin, path.join(binDir, binName)); + assert.deepStrictEqual(result.prefix, []); + }); + + run('resolveFormatterBin: falls back to npx for prettier', () => { + const root = makeTmpDir(); + const result = resolveFormatterBin(root, 'prettier'); + const expectedBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + assert.strictEqual(result.bin, expectedBin); + assert.deepStrictEqual(result.prefix, ['prettier']); + }); + + run('resolveFormatterBin: returns null for unknown formatter', () => { + const root = makeTmpDir(); + const result = resolveFormatterBin(root, 'unknown'); + assert.strictEqual(result, null); + }); + + run('resolveFormatterBin: caches resolved binary', () => { + const root = makeTmpDir(); + const binDir = path.join(root, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const binName = process.platform === 'win32' ? 'biome.cmd' : 'biome'; + fs.writeFileSync(path.join(binDir, binName), ''); + + const first = resolveFormatterBin(root, 'biome'); + fs.unlinkSync(path.join(binDir, binName)); + const second = resolveFormatterBin(root, 'biome'); + + assert.strictEqual(first.bin, second.bin); + }); + + // ── clearCaches ─────────────────────────────────────────────── + + run('clearCaches: clears all cached values', () => { + const root = makeTmpDir(); + fs.writeFileSync(path.join(root, 'package.json'), '{}'); + fs.writeFileSync(path.join(root, 'biome.json'), '{}'); + + findProjectRoot(root); + detectFormatter(root); + resolveFormatterBin(root, 'biome'); + + clearCaches(); + + // After clearing, removing config should change detection + fs.unlinkSync(path.join(root, 'biome.json')); + assert.strictEqual(detectFormatter(root), null); + }); + + // ── Summary & Cleanup ───────────────────────────────────────── + + cleanupTmpDirs(); + + console.log('\n=== Test Results ==='); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log(`Total: ${passed + failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/run-all.js b/tests/run-all.js index 54d8c212d..41a6c7f7b 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -16,6 +16,7 @@ const testFiles = [ 'lib/session-manager.test.js', 'lib/session-aliases.test.js', 'lib/project-detect.test.js', + 'lib/resolve-formatter.test.js', 'hooks/hooks.test.js', 'hooks/evaluate-session.test.js', 'hooks/suggest-compact.test.js', @@ -27,7 +28,7 @@ const testFiles = [ ]; const BOX_W = 58; // inner width between ║ delimiters -const boxLine = (s) => `║${s.padEnd(BOX_W)}║`; +const boxLine = s => `║${s.padEnd(BOX_W)}║`; console.log('╔' + '═'.repeat(BOX_W) + '╗'); console.log(boxLine(' Everything Claude Code - Test Suite'));