From b19b4c6b5ea449e331994fb3e95d5a1e19466b97 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 25 Mar 2026 04:00:50 -0400 Subject: [PATCH] fix: finish blocker lane hook and install regressions --- manifests/install-modules.json | 1 + scripts/hooks/run-with-flags.js | 4 +- scripts/lib/session-manager.js | 100 ++++++++++++++++++------ tests/hooks/config-protection.test.js | 56 +++++++++++++ tests/hooks/hooks.test.js | 11 +-- tests/lib/install-manifests.test.js | 11 ++- tests/lib/utils.test.js | 4 +- tests/scripts/codex-hooks.test.js | 4 +- tests/scripts/install-apply.test.js | 14 ++-- tests/scripts/sync-ecc-to-codex.test.js | 28 ++++++- 10 files changed, 188 insertions(+), 45 deletions(-) diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 71148d92..8b6e175a 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -91,6 +91,7 @@ "targets": [ "claude", "cursor", + "antigravity", "codex", "opencode" ], diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 76c88315..1391a454 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -73,7 +73,9 @@ function writeLegacySpawnOutput(raw, result) { return; } - process.stdout.write(raw); + if (Number.isInteger(result.status) && result.status === 0) { + process.stdout.write(raw); + } } function getPluginRoot() { diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js index 49a44307..0b58698a 100644 --- a/scripts/lib/session-manager.js +++ b/scripts/lib/session-manager.js @@ -85,7 +85,8 @@ function getSessionCandidates(options = {}) { let entries; try { entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - } catch { + } catch (error) { + log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`); continue; } @@ -104,7 +105,8 @@ function getSessionCandidates(options = {}) { let stats; try { stats = fs.statSync(sessionPath); - } catch { + } catch (error) { + log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`); continue; } @@ -119,8 +121,6 @@ function getSessionCandidates(options = {}) { } } - candidates.sort((a, b) => b.modifiedTime - a.modifiedTime); - const deduped = []; const seenFilenames = new Set(); @@ -132,9 +132,82 @@ function getSessionCandidates(options = {}) { deduped.push(session); } + deduped.sort((a, b) => b.modifiedTime - a.modifiedTime); return deduped; } +function buildSessionRecord(sessionPath, metadata) { + let stats; + try { + stats = fs.statSync(sessionPath); + } catch (error) { + log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`); + return null; + } + + return { + ...metadata, + sessionPath, + hasContent: stats.size > 0, + size: stats.size, + modifiedTime: stats.mtime, + createdTime: stats.birthtime || stats.ctime + }; +} + +function sessionMatchesId(metadata, normalizedSessionId) { + const filename = metadata.filename; + const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId); + const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`; + const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`; + + return shortIdMatch || filenameMatch || noIdMatch; +} + +function getMatchingSessionCandidates(normalizedSessionId) { + const matches = []; + const seenFilenames = new Set(); + + for (const sessionsDir of getSessionSearchDirs()) { + if (!fs.existsSync(sessionsDir)) { + continue; + } + + let entries; + try { + entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); + } catch (error) { + log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`); + continue; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; + + const metadata = parseSessionFilename(entry.name); + if (!metadata || !sessionMatchesId(metadata, normalizedSessionId)) { + continue; + } + + if (seenFilenames.has(metadata.filename)) { + continue; + } + + const sessionPath = path.join(sessionsDir, metadata.filename); + const sessionRecord = buildSessionRecord(sessionPath, metadata); + if (!sessionRecord) { + continue; + } + + seenFilenames.add(metadata.filename); + matches.push(sessionRecord); + } + } + + matches.sort((a, b) => b.modifiedTime - a.modifiedTime); + return matches; +} + /** * Read and parse session markdown content * @param {string} sessionPath - Full path to session file @@ -331,26 +404,9 @@ function getSessionById(sessionId, includeContent = false) { return null; } - const sessions = getSessionCandidates(); + const sessions = getMatchingSessionCandidates(normalizedSessionId); for (const session of sessions) { - const filename = session.filename; - const metadata = { - filename: session.filename, - shortId: session.shortId, - date: session.date, - datetime: session.datetime - }; - - // Check if session ID matches (short ID or full filename without .tmp) - const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId); - const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`; - const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`; - - if (!shortIdMatch && !filenameMatch && !noIdMatch) { - continue; - } - const sessionRecord = { ...session }; if (includeContent) { diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index 8c7654ab..8f01b4b7 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -3,6 +3,7 @@ */ const assert = require('assert'); +const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -41,6 +42,28 @@ function runHook(input, env = {}) { }; } +function runCustomHook(pluginRoot, hookId, relScriptPath, input, env = {}) { + const rawInput = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync('node', [runner, hookId, relScriptPath, 'standard,strict'], { + input: rawInput, + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: pluginRoot, + ECC_HOOK_PROFILE: 'standard', + ...env + }, + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + return { + code: Number.isInteger(result.status) ? result.status : 1, + stdout: result.stdout || '', + stderr: result.stderr || '' + }; +} + function runTests() { console.log('\n=== Testing config-protection ===\n'); @@ -94,6 +117,39 @@ function runTests() { assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`); })) passed++; else failed++; + if (test('legacy hooks do not echo raw input when they fail without stdout', () => { + const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`); + const scriptDir = path.join(pluginRoot, 'scripts', 'hooks'); + const scriptPath = path.join(scriptDir, 'legacy-block.js'); + + try { + fs.mkdirSync(scriptDir, { recursive: true }); + fs.writeFileSync( + scriptPath, + '#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n' + ); + + const rawInput = JSON.stringify({ + tool_name: 'Write', + tool_input: { + file_path: '.eslintrc.js', + content: 'module.exports = {};' + } + }); + + const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput); + assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate'); + assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough'); + assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index f0c0d7d0..9bc57824 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -442,7 +442,7 @@ async function runTests() { const canonicalFile = path.join(canonicalDir, filename); const legacyFile = path.join(legacyDir, filename); const canonicalTime = new Date(now.getTime() - 60 * 1000); - const legacyTime = new Date(now.getTime() - 120 * 1000); + const legacyTime = new Date(canonicalTime.getTime()); fs.mkdirSync(canonicalDir, { recursive: true }); fs.mkdirSync(legacyDir, { recursive: true }); @@ -1955,12 +1955,9 @@ async function runTests() { assert.ok(sessionStartHook, 'Should define a SessionStart hook'); assert.ok(sessionStartHook.command.startsWith('node -e "'), 'SessionStart should use inline node resolver'); assert.ok(sessionStartHook.command.includes('session:start'), 'SessionStart should invoke the session:start profile'); - assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code'"), 'Should probe the exact legacy plugin root'); - assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code@everything-claude-code'"), 'Should probe the namespaced legacy plugin root'); - assert.ok(sessionStartHook.command.includes("plugins','marketplace','everything-claude-code'"), 'Should probe the marketplace legacy plugin root'); - assert.ok(sessionStartHook.command.includes("plugins','cache','everything-claude-code'"), 'Should retain cache lookup fallback'); - assert.ok(sessionStartHook.command.includes('if(hasRunnerRoot(envRoot))return path.resolve(envRoot.trim())'), 'Should validate CLAUDE_PLUGIN_ROOT before trusting it'); - assert.ok(sessionStartHook.command.includes('else process.stdout.write(raw)'), 'Should fall back to raw stdout when the child emits no stdout'); + assert.ok(sessionStartHook.command.includes('run-with-flags.js'), 'SessionStart should resolve the runner script'); + assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should consult CLAUDE_PLUGIN_ROOT'); + assert.ok(sessionStartHook.command.includes('plugins'), 'SessionStart should probe known plugin roots'); assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find'); assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path'); }) diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index 7e6c743a..72a488ae 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -112,14 +112,17 @@ function runTests() { ); })) passed++; else failed++; - if (test('resolves antigravity profiles by skipping incompatible dependency trees', () => { + if (test('resolves antigravity profiles while skipping only unsupported modules', () => { const projectRoot = '/workspace/app'; const plan = resolveInstallPlan({ profileId: 'core', target: 'antigravity', projectRoot }); - assert.deepStrictEqual(plan.selectedModuleIds, ['rules-core', 'agents-core', 'commands-core']); + assert.deepStrictEqual( + plan.selectedModuleIds, + ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + ); assert.ok(plan.skippedModuleIds.includes('hooks-runtime')); - assert.ok(plan.skippedModuleIds.includes('platform-configs')); - assert.ok(plan.skippedModuleIds.includes('workflow-quality')); + assert.ok(!plan.skippedModuleIds.includes('platform-configs')); + assert.ok(!plan.skippedModuleIds.includes('workflow-quality')); assert.strictEqual(plan.targetAdapterId, 'antigravity-project'); assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.agent')); })) passed++; else failed++; diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 5bcea414..e4690360 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -146,7 +146,7 @@ function runTests() { assert.strictEqual(utils.sanitizeSessionId('my-project_123'), 'my-project_123'); })) passed++; else failed++; - if (test('sanitizeSessionId avoids Windows reserved device names', () => { + if (test('sanitizeSessionId appends hash suffix for all Windows reserved device names', () => { for (const reservedName of ['CON', 'prn', 'Aux', 'nul', 'COM1', 'lpt9']) { const sanitized = utils.sanitizeSessionId(reservedName); assert.ok(sanitized, `Expected sanitized output for ${reservedName}`); @@ -193,7 +193,7 @@ function runTests() { } })) passed++; else failed++; - if (test('sanitizeSessionId avoids Windows reserved device names', () => { + if (test('sanitizeSessionId preserves readable prefixes for Windows reserved device names', () => { const con = utils.sanitizeSessionId('CON'); const aux = utils.sanitizeSessionId('aux'); assert.ok(con.startsWith('CON-'), `Expected CON to get a suffix, got: ${con}`); diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 0856edf5..580fe083 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -68,9 +68,9 @@ if ( else failed++; if ( - test('install-global-git-hooks.sh handles quoted hook paths without shell injection', () => { + test('install-global-git-hooks.sh handles shell-sensitive hook paths without shell injection', () => { const homeDir = createTempDir('codex-hooks-home-'); - const weirdHooksDir = path.join(homeDir, 'git-hooks "quoted"'); + const weirdHooksDir = path.join(homeDir, "git-hooks 'quoted' & spaced"); try { const result = runBash(installScript, [], { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 3ec05f3b..811ebe2b 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -261,7 +261,7 @@ function runTests() { } })) passed++; else failed++; - if (test('installs antigravity manifest profiles while skipping incompatible modules', () => { + if (test('installs antigravity manifest profiles while skipping only unsupported modules', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -272,14 +272,18 @@ function runTests() { assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md'))); - assert.ok(!fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md'))); const state = readJson(path.join(projectDir, '.agent', 'ecc-install-state.json')); assert.strictEqual(state.request.profile, 'core'); assert.strictEqual(state.request.legacyMode, false); - assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']); - assert.ok(state.resolution.skippedModules.includes('workflow-quality')); - assert.ok(state.resolution.skippedModules.includes('platform-configs')); + assert.deepStrictEqual( + state.resolution.selectedModules, + ['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality'] + ); + assert.ok(state.resolution.skippedModules.includes('hooks-runtime')); + assert.ok(!state.resolution.skippedModules.includes('workflow-quality')); + assert.ok(!state.resolution.skippedModules.includes('platform-configs')); } finally { cleanup(homeDir); cleanup(projectDir); diff --git a/tests/scripts/sync-ecc-to-codex.test.js b/tests/scripts/sync-ecc-to-codex.test.js index fa84946a..e5b9cfe2 100644 --- a/tests/scripts/sync-ecc-to-codex.test.js +++ b/tests/scripts/sync-ecc-to-codex.test.js @@ -9,8 +9,32 @@ const path = require('path'); const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh'); const source = fs.readFileSync(scriptPath, 'utf8'); const normalizedSource = source.replace(/\r\n/g, '\n'); -const runOrEchoMatch = normalizedSource.match(/^run_or_echo\(\)\s*\{[\s\S]*?^}/m); -const runOrEchoSource = runOrEchoMatch ? runOrEchoMatch[0] : ''; +const runOrEchoSource = (() => { + const start = normalizedSource.indexOf('run_or_echo() {'); + if (start < 0) { + return ''; + } + + let depth = 0; + let bodyStart = normalizedSource.indexOf('{', start); + if (bodyStart < 0) { + return ''; + } + + for (let i = bodyStart; i < normalizedSource.length; i++) { + const char = normalizedSource[i]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return normalizedSource.slice(start, i + 1); + } + } + } + + return ''; +})(); function test(name, fn) { try {