Files
evomap__evolver/test/adapters.test.js
2026-06-09 20:14:33 +09:00

1035 lines
45 KiB
JavaScript

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { canCreateSymlinks } = require('./helpers/symlink');
// Symlink-rejection tests need to plant a real symlink before exercising
// the code under test. On Windows without admin / developer-mode that
// fails in setup with EPERM, so we route those tests through `symlinkIt`
// which becomes a no-op skip when symlink creation isn't available.
const symlinkIt = canCreateSymlinks() ? it : it.skip;
const hookAdapter = require('../src/adapters/hookAdapter');
const cursorAdapter = require('../src/adapters/cursor');
const claudeAdapter = require('../src/adapters/claudeCode');
const codexAdapter = require('../src/adapters/codex');
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-hooks-test-'));
}
function cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
// -- hookAdapter --
describe('hookAdapter', () => {
describe('detectPlatform', () => {
it('detects cursor from .cursor directory', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
assert.equal(hookAdapter.detectPlatform(tmp), 'cursor');
} finally { cleanup(tmp); }
});
it('detects claude-code from .claude directory', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
assert.equal(hookAdapter.detectPlatform(tmp), 'claude-code');
} finally { cleanup(tmp); }
});
it('detects codex from .codex directory', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
assert.equal(hookAdapter.detectPlatform(tmp), 'codex');
} finally { cleanup(tmp); }
});
it('returns null for unknown platform when no fallback dirs exist', () => {
const tmp = makeTmpDir();
try {
// detectPlatform checks cwd first, then homedir fallback.
// On machines with ~/.cursor, it will find cursor via fallback.
// This test only asserts that the cwd itself yields nothing.
const result = hookAdapter.detectPlatform(tmp);
// If homedir has a platform dir, the function returns that.
// We just verify the function doesn't crash and returns a valid result.
assert.ok(result === null || typeof result === 'string');
} finally { cleanup(tmp); }
});
});
describe('deepMerge', () => {
it('merges nested objects', () => {
const a = { x: { a: 1 }, y: 2 };
const b = { x: { b: 3 }, z: 4 };
const result = hookAdapter.deepMerge(a, b);
assert.deepEqual(result, { x: { a: 1, b: 3 }, y: 2, z: 4 });
});
it('overwrites arrays', () => {
const a = { arr: [1, 2] };
const b = { arr: [3, 4, 5] };
const result = hookAdapter.deepMerge(a, b);
assert.deepEqual(result.arr, [3, 4, 5]);
});
});
describe('mergeJsonFile', () => {
it('creates file if not exists', () => {
const tmp = makeTmpDir();
try {
const filePath = path.join(tmp, 'test.json');
hookAdapter.mergeJsonFile(filePath, { hooks: { a: 1 } });
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.equal(data.hooks.a, 1);
assert.equal(data._evolver_managed, true);
} finally { cleanup(tmp); }
});
it('merges into existing file', () => {
const tmp = makeTmpDir();
try {
const filePath = path.join(tmp, 'test.json');
fs.writeFileSync(filePath, JSON.stringify({ existing: true, hooks: { old: 1 } }));
hookAdapter.mergeJsonFile(filePath, { hooks: { new: 2 } });
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.equal(data.existing, true);
assert.equal(data.hooks.old, 1);
assert.equal(data.hooks.new, 2);
assert.equal(data._evolver_managed, true);
} finally { cleanup(tmp); }
});
});
describe('appendSectionToFile', () => {
it('appends section to new file', () => {
const tmp = makeTmpDir();
try {
const filePath = path.join(tmp, 'README.md');
const result = hookAdapter.appendSectionToFile(filePath, '<!-- marker -->', '<!-- marker -->\nHello');
assert.equal(result, true);
const content = fs.readFileSync(filePath, 'utf8');
assert.ok(content.includes('<!-- marker -->'));
assert.ok(content.includes('Hello'));
} finally { cleanup(tmp); }
});
it('does not duplicate if marker exists', () => {
const tmp = makeTmpDir();
try {
const filePath = path.join(tmp, 'README.md');
fs.writeFileSync(filePath, '<!-- marker -->\nExisting');
const result = hookAdapter.appendSectionToFile(filePath, '<!-- marker -->', '<!-- marker -->\nDuplicate');
assert.equal(result, false);
} finally { cleanup(tmp); }
});
});
describe('copyHookScripts', () => {
it('copies scripts to destination', () => {
const tmp = makeTmpDir();
try {
const destDir = path.join(tmp, 'hooks');
const evolverRoot = path.resolve(__dirname, '..');
const copied = hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
// 4 hook entry points (session-start, signal-detect, session-end,
// task-recall) + 2 helpers (`_runtimePaths.js`, `_memoryFiltering.js`)
// required by them via `require('./...')`. Helper list is verified
// separately by the "#547 — setup-hooks copies every helper required by
// the entry-point scripts" suite, which scans the actual `require`
// statements; this assertion is just a quick smoke test on count.
assert.equal(copied.length, 6);
for (const f of copied) {
assert.ok(fs.existsSync(f));
}
} finally { cleanup(tmp); }
});
it('includes _runtimePaths.js so copied session hooks can require it (PR #94)', () => {
const tmp = makeTmpDir();
try {
const destDir = path.join(tmp, 'hooks');
const evolverRoot = path.resolve(__dirname, '..');
hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
assert.ok(fs.existsSync(path.join(destDir, '_runtimePaths.js')),
'_runtimePaths.js must ship alongside session-start/end or both crash with MODULE_NOT_FOUND');
// End-to-end: actually run the copied script. If `_runtimePaths.js`
// is missing the require() at top of file would fail with
// MODULE_NOT_FOUND and exit non-zero.
const { spawnSync } = require('child_process');
const result = spawnSync('node', [path.join(destDir, 'evolver-session-start.js')], {
input: '{}', encoding: 'utf8', timeout: 5000,
});
assert.equal(result.status, 0,
`copied evolver-session-start.js must run without error. stderr=${result.stderr}`);
} finally { cleanup(tmp); }
});
it('copied evolver-task-recall.js runs fail-open (exit 0 + parseable {}) with mode=off', () => {
// The require-scanner suite only verifies same-dir `./_helper` requires;
// evolver-task-recall.js resolves its core via an ABSOLUTE require
// (findEvolverRoot()+src/gep/recallInject.js), which that scanner cannot
// cover. This e2e proves the copied hook (a) parses its stdin, (b) never
// crashes, and (c) emits exactly one JSON object — the hook contract.
const tmp = makeTmpDir();
try {
const destDir = path.join(tmp, 'hooks');
const evolverRoot = path.resolve(__dirname, '..');
hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
assert.ok(fs.existsSync(path.join(destDir, 'evolver-task-recall.js')),
'evolver-task-recall.js must be copied by setup-hooks');
const { spawnSync } = require('child_process');
const result = spawnSync('node', [path.join(destDir, 'evolver-task-recall.js')], {
input: JSON.stringify({ prompt: 'add retry with backoff to the http client', session_id: 'sess-test' }),
encoding: 'utf8',
timeout: 8000,
// mode=off: must finish {} without parsing/sending the prompt.
env: { ...process.env, EVOLVER_RECALL_MODE: 'off', A2A_HUB_URL: '' },
});
assert.equal(result.status, 0,
`copied evolver-task-recall.js must exit 0 (fail-open). stderr=${result.stderr}`);
const parsed = JSON.parse(result.stdout || '{}');
assert.deepEqual(parsed, {}, 'mode=off must inject nothing (empty object)');
} finally { cleanup(tmp); }
});
});
describe('removeHookScripts', () => {
it('removes evolver scripts', () => {
const tmp = makeTmpDir();
try {
const hooksDir = path.join(tmp, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, '_runtimePaths.js'), '');
fs.writeFileSync(path.join(hooksDir, 'evolver-session-start.js'), '');
fs.writeFileSync(path.join(hooksDir, 'evolver-signal-detect.js'), '');
fs.writeFileSync(path.join(hooksDir, 'evolver-session-end.js'), '');
fs.writeFileSync(path.join(hooksDir, 'user-custom.js'), '');
const removed = hookAdapter.removeHookScripts(hooksDir);
assert.equal(removed, 4);
assert.ok(fs.existsSync(path.join(hooksDir, 'user-custom.js')));
} finally { cleanup(tmp); }
});
});
describe('mergeWithHooksUnion (#539)', () => {
it('preserves user-installed hooks under same event when adding evolver hooks', () => {
const userHooks = {
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node user-tool.js' }] },
],
SessionStart: [
{ hooks: [{ type: 'command', command: 'node user-init.js' }] },
],
},
};
const evolverPatch = {
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' }] },
],
SessionStart: [
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-start.js' }] },
],
},
};
const merged = hookAdapter.mergeWithHooksUnion(userHooks, evolverPatch);
assert.equal(merged.hooks.Stop.length, 2, 'Stop must contain both user and evolver entries');
assert.equal(merged.hooks.SessionStart.length, 2, 'SessionStart must contain both');
const stopCmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
assert.ok(stopCmds.includes('node user-tool.js'), 'user Stop hook must be preserved');
assert.ok(stopCmds.some(c => c.includes('evolver-session-end')), 'evolver Stop hook must be added');
});
it('refreshes (does not duplicate) evolver-owned entries on reinstall', () => {
const previousInstall = {
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node OLD/evolver-session-end.js' }] },
],
},
};
const newPatch = {
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node NEW/evolver-session-end.js' }] },
],
},
};
const merged = hookAdapter.mergeWithHooksUnion(previousInstall, newPatch);
assert.equal(merged.hooks.Stop.length, 1, 'evolver entry must be refreshed, not duplicated');
const cmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
assert.ok(cmds[0].includes('NEW/'));
assert.ok(!cmds.some(c => c.includes('OLD/')));
});
it('handles flat (Codex) command shape', () => {
const userHooks = {
hooks: {
Stop: [{ type: 'command', command: 'node user-tool.js' }],
},
};
const evolverPatch = {
hooks: {
Stop: [{ type: 'command', command: 'node .codex/hooks/evolver-session-end.js' }],
},
};
const merged = hookAdapter.mergeWithHooksUnion(userHooks, evolverPatch);
assert.equal(merged.hooks.Stop.length, 2);
assert.ok(merged.hooks.Stop.some(h => h.command === 'node user-tool.js'));
assert.ok(merged.hooks.Stop.some(h => h.command.includes('evolver-session-end')));
});
});
describe('removeMarkedSection (#538)', () => {
it('removes evolver section without consuming user H2 below', () => {
const tmp = makeTmpDir();
try {
const file = path.join(tmp, 'AGENTS.md');
const userBefore = '# Project\n\nIntro paragraph.\n\n';
const evolverSection =
'<!-- evolver-evolution-memory -->\n' +
'## Evolution Memory (Evolver)\n\n' +
'Body 1\nBody 2\n\n';
const userAfter = '## Other Section\n\nUser content here.\n';
fs.writeFileSync(file, userBefore + evolverSection + userAfter);
const ok = hookAdapter.removeMarkedSection(file, '<!-- evolver-evolution-memory -->');
assert.equal(ok, true);
const next = fs.readFileSync(file, 'utf8');
assert.ok(!next.includes('evolver-evolution-memory'), 'marker must be gone');
assert.ok(!next.includes('## Evolution Memory'), 'evolver heading must be gone');
assert.ok(next.includes('## Other Section'), 'user section must remain');
assert.ok(next.includes('User content here.'), 'user content must remain');
assert.ok(next.includes('Intro paragraph'), 'pre-marker content must remain');
} finally { cleanup(tmp); }
});
it('handles trailing-only evolver section (no user content after)', () => {
const tmp = makeTmpDir();
try {
const file = path.join(tmp, 'AGENTS.md');
fs.writeFileSync(file,
'# Project\n\nIntro\n\n' +
'<!-- evolver-evolution-memory -->\n' +
'## Evolution Memory (Evolver)\n\nBody\n');
hookAdapter.removeMarkedSection(file, '<!-- evolver-evolution-memory -->');
const next = fs.readFileSync(file, 'utf8');
assert.ok(!next.includes('Evolution Memory'));
assert.ok(next.includes('Intro'));
} finally { cleanup(tmp); }
});
it('returns false when marker absent', () => {
const tmp = makeTmpDir();
try {
const file = path.join(tmp, 'AGENTS.md');
fs.writeFileSync(file, '# Project\n');
assert.equal(hookAdapter.removeMarkedSection(file, '<!-- nope -->'), false);
} finally { cleanup(tmp); }
});
it('returns false when file missing', () => {
const tmp = makeTmpDir();
try {
assert.equal(
hookAdapter.removeMarkedSection(path.join(tmp, 'missing.md'), '<!-- x -->'),
false
);
} finally { cleanup(tmp); }
});
});
describe('assertSafeConfigDir (PR #94 round-4 + round-5)', () => {
symlinkIt('throws when the config dir is a symbolic link', () => {
const tmp = makeTmpDir();
try {
const target = path.join(tmp, 'real-target');
fs.mkdirSync(target, { recursive: true });
const linkPath = path.join(tmp, '.codex');
fs.symlinkSync(target, linkPath, 'dir');
assert.throws(
() => hookAdapter.assertSafeConfigDir(linkPath, '.codex'),
/symbolic link/i,
'config dir that is a symlink must be refused — hostile workspace could redirect writes outside repo'
);
} finally { cleanup(tmp); }
});
it('returns silently for a real directory or missing path', () => {
const tmp = makeTmpDir();
try {
const realDir = path.join(tmp, '.codex');
fs.mkdirSync(realDir, { recursive: true });
assert.doesNotThrow(() => hookAdapter.assertSafeConfigDir(realDir, '.codex'));
assert.doesNotThrow(() => hookAdapter.assertSafeConfigDir(path.join(tmp, '.missing'), '.missing'));
} finally { cleanup(tmp); }
});
symlinkIt('codex install/uninstall refuses .codex symlink', () => {
const tmp = makeTmpDir();
try {
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(tmp, '.codex'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
assert.throws(
() => codexAdapter.uninstall({ configRoot: tmp }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
symlinkIt('claude-code install/uninstall refuses .claude symlink', () => {
const tmp = makeTmpDir();
try {
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(tmp, '.claude'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
assert.throws(
() => claudeAdapter.uninstall({ configRoot: tmp }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
// Round-5: a hostile workspace can keep `.codex` as a real directory
// and only symlink the nested `hooks/` (or `plugins/`) dir. The
// top-level guard from round-4 missed this, so copyHookScripts /
// removeHookScripts ran through the symlink and could touch files
// outside the workspace. Bugbot HIGH severity finding for round-5.
symlinkIt('rejects symlinked nested subdirs via subdirs option', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.codex');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
assert.throws(
() => hookAdapter.assertSafeConfigDir(realConfig, '.codex', { subdirs: ['hooks'] }),
/symbolic link/i,
'symlinked .codex/hooks must be refused even when .codex itself is real'
);
} finally { cleanup(tmp); }
});
symlinkIt('codex install/uninstall refuses .codex/hooks symlink', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.codex');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
assert.throws(
() => codexAdapter.uninstall({ configRoot: tmp }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
symlinkIt('claude-code install/uninstall refuses .claude/hooks symlink', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.claude');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
assert.throws(
() => claudeAdapter.uninstall({ configRoot: tmp }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
});
describe('evolver-session-end runGit fail/empty distinction (PR #94 round-6)', () => {
// Round-6 LOW: runGit previously returned `''` for both
// "command failed" (e.g. no HEAD~1 in fresh repo) and
// "command succeeded with empty output" (e.g. empty merge).
// The `||` chain in getGitDiffStats() then incorrectly fell
// through to the working-tree diff on a successful empty result,
// surfacing unrelated unstaged changes as the session outcome.
// Pin the {ok, out} contract at the source level.
it('runGit returns {ok, out} so callers can distinguish failure from empty', () => {
const src = fs.readFileSync(
path.resolve(__dirname, '..', 'src', 'adapters', 'scripts', 'evolver-session-end.js'),
'utf8'
);
assert.ok(
/return\s*\{\s*ok:\s*true/.test(src),
'runGit success branch must return an object with `ok: true`'
);
assert.ok(
/return\s*\{\s*ok:\s*false/.test(src),
'runGit failure branch must return an object with `ok: false`'
);
// Make sure the call sites use the .ok flag rather than relying on
// truthy stdout — the previous bug was a `||` on the stdout string.
assert.ok(
/\.ok\s*\?[^?]*runGit\(/.test(src) || /\.ok\s*\?\s*[A-Za-z0-9_]+\.out/.test(src),
'getGitDiffStats must check .ok before falling back, not chain `||` on output'
);
});
});
describe('copyHookScripts symlinked-destination guard (PR #94 round-6)', () => {
// Round-6 HIGH: round-5 closed the directory hole, but a hostile
// workspace can still pre-create individual hook *files* as symlinks
// pointing at writable targets outside the project. fs.copyFileSync
// follows symlinks at the destination, so the source content lands
// on the attacker's chosen file.
symlinkIt('refuses to copy when destination file is a symlink', () => {
const tmp = makeTmpDir();
try {
const evolverRoot = path.resolve(__dirname, '..');
const destDir = path.join(tmp, 'hooks');
fs.mkdirSync(destDir, { recursive: true });
const hostileTarget = path.join(tmp, 'attacker-target');
fs.writeFileSync(hostileTarget, 'original\n');
fs.symlinkSync(
hostileTarget,
path.join(destDir, 'evolver-session-end.js'),
'file'
);
assert.throws(
() => hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters')),
/symbolic link/i,
'pre-planted symlink at hook destination must be refused'
);
// Attacker target must remain untouched.
assert.equal(fs.readFileSync(hostileTarget, 'utf8'), 'original\n');
} finally { cleanup(tmp); }
});
});
describe('_runtimePaths security (PR #94 round-2)', () => {
it('findEvolverRoot does not trust process.cwd() / hostile workspace node_modules', () => {
// A hostile workspace could plant
// `node_modules/@evomap/evolver/package.json` and previously have it
// resolved as the trusted evolver root, letting the workspace control
// findMemoryGraph() — which feeds attacker text into the
// session-start `additionalContext` (prompt injection).
// The fix removed `process.cwd()` from the require.resolve paths.
// The paths array is now built dynamically by
// _buildInstallSearchPaths() (so we can add Apple Silicon Homebrew
// / NVM / fnm / Volta / asdf without bloating the call site), so
// test the runtime invariant: the produced paths must never
// include process.cwd() or a node_modules directory under it.
const runtimePaths = require('../src/adapters/scripts/_runtimePaths');
const paths = runtimePaths.__internals.buildInstallSearchPaths();
const cwd = process.cwd();
const cwdNm = path.join(cwd, 'node_modules');
// The actual security invariant is "process.cwd() itself (or
// cwd/node_modules) must not appear in the allowlist". The
// tempting `p.startsWith(cwd + path.sep)` check is too broad —
// all user-scoped paths are rooted at os.homedir(), so the test
// would fire false positives whenever cwd === os.homedir() (user
// runs evolver from their home dir), cwd === '/home' (Linuxbrew
// path begins with /home/), or cwd === '/opt' (Apple Silicon
// Homebrew path begins with /opt/). Bugbot PR #165 Medium.
for (const p of paths) {
assert.notEqual(p, cwd,
'install search list MUST NOT include process.cwd() — hostile workspace could plant @evomap/evolver and hijack findMemoryGraph');
assert.notEqual(p, cwdNm,
'install search list MUST NOT include cwd/node_modules either');
}
});
it('findEvolverRoot ignores a hostile node_modules in cwd', () => {
const tmp = makeTmpDir();
const origCwd = process.cwd();
try {
// Plant a fake @evomap/evolver under the tmp dir.
const hostile = path.join(tmp, 'node_modules', '@evomap', 'evolver');
fs.mkdirSync(hostile, { recursive: true });
fs.writeFileSync(
path.join(hostile, 'package.json'),
JSON.stringify({ name: '@evomap/evolver', version: '0.0.1-hostile' })
);
process.chdir(tmp);
// Re-require fresh so any internal state is clean. _runtimePaths is
// pure (no module-level side effects), but this is defensive.
delete require.cache[require.resolve('../src/adapters/scripts/_runtimePaths')];
const { findEvolverRoot } = require('../src/adapters/scripts/_runtimePaths');
const root = findEvolverRoot();
// The dev/repo relative walk (which doesn't depend on cwd) should
// resolve to the real evolver root. Either way, the hostile path
// must NEVER be returned.
assert.notEqual(root, hostile,
'hostile node_modules/@evomap/evolver under cwd must not be selected as evolver root');
} finally {
process.chdir(origCwd);
cleanup(tmp);
}
});
});
});
// -- Cursor adapter --
describe('cursor adapter', () => {
it('installs hooks correctly', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
const result = cursorAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(result.ok, true);
assert.equal(result.platform, 'cursor');
const hooks = JSON.parse(fs.readFileSync(path.join(tmp, '.cursor', 'hooks.json'), 'utf8'));
assert.ok(hooks.hooks.sessionStart);
assert.ok(hooks.hooks.afterFileEdit);
assert.ok(hooks.hooks.stop);
assert.equal(hooks._evolver_managed, true);
assert.ok(fs.existsSync(path.join(tmp, '.cursor', 'hooks', 'evolver-session-start.js')));
} finally { cleanup(tmp); }
});
it('uninstalls hooks correctly', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
cursorAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = cursorAdapter.uninstall({ configRoot: tmp, evolverRoot });
assert.equal(result.ok, true);
assert.equal(result.removed, true);
assert.ok(!fs.existsSync(path.join(tmp, '.cursor', 'hooks', 'evolver-session-start.js')));
} finally { cleanup(tmp); }
});
it('buildHooksJson returns valid structure', () => {
const hooks = cursorAdapter.buildHooksJson('/evolver', false);
assert.equal(hooks.version, 1);
assert.ok(hooks.hooks.sessionStart[0].command.includes('evolver-session-start'));
assert.ok(hooks.hooks.afterFileEdit[0].command.includes('evolver-signal-detect'));
assert.ok(hooks.hooks.stop[0].command.includes('evolver-session-end'));
});
it('buildHooksJson user-level uses ./hooks/ prefix', () => {
const hooks = cursorAdapter.buildHooksJson('/evolver', true);
assert.ok(hooks.hooks.sessionStart[0].command.startsWith('node ./hooks/'));
});
});
// -- Claude Code adapter --
describe('claudeCode adapter', () => {
it('installs hooks and CLAUDE.md', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
const result = claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(result.ok, true);
assert.equal(result.platform, 'claude-code');
const settings = JSON.parse(fs.readFileSync(path.join(tmp, '.claude', 'settings.json'), 'utf8'));
assert.ok(settings.hooks.SessionStart);
assert.ok(settings.hooks.PostToolUse);
assert.ok(settings.hooks.Stop);
assert.ok(fs.existsSync(path.join(tmp, 'CLAUDE.md')));
const claudeMd = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf8');
assert.ok(claudeMd.includes('Evolution Memory'));
} finally { cleanup(tmp); }
});
it('uninstalls hooks and CLAUDE.md section', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = claudeAdapter.uninstall({ configRoot: tmp });
assert.equal(result.ok, true);
assert.equal(result.removed, true);
const claudeMd = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf8');
assert.ok(!claudeMd.includes('evolver-evolution-memory'));
} finally { cleanup(tmp); }
});
it('install preserves user-installed Stop hook (#539)', () => {
const tmp = makeTmpDir();
try {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const settingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node user-cleanup.js' }] },
],
},
}));
const evolverRoot = path.resolve(__dirname, '..');
claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const merged = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(merged.hooks.Stop.length, 2,
'user Stop entry + evolver Stop entry must both be present');
const cmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
assert.ok(cmds.includes('node user-cleanup.js'));
assert.ok(cmds.some(c => c.includes('evolver-session-end')));
} finally { cleanup(tmp); }
});
it('uninstall strips evolver hooks even without _evolver_managed marker (#538)', () => {
const tmp = makeTmpDir();
try {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const settingsPath = path.join(claudeDir, 'settings.json');
// Hand-edited / older install: marker dropped but evolver entry remains.
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' }] },
{ hooks: [{ type: 'command', command: 'node user-cleanup.js' }] },
],
},
}));
claudeAdapter.uninstall({ configRoot: tmp });
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const remaining = (after.hooks && after.hooks.Stop)
? after.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command))
: [];
assert.ok(!remaining.some(c => c.includes('evolver-session-end')),
'evolver hook must be filtered even without marker');
assert.ok(remaining.includes('node user-cleanup.js'),
'user hook must remain');
} finally { cleanup(tmp); }
});
it('uninstall persists inner-array filter when matcher mixes evolver + user hooks (PR #94)', () => {
const tmp = makeTmpDir();
try {
const claudeDir = path.join(tmp, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const settingsPath = path.join(claudeDir, 'settings.json');
// No _evolver_managed marker, single matcher contains both evolver
// and user hooks. Outer array length is unchanged after filtering;
// the bug was that `touched` only flipped on outer-length changes.
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
Stop: [
{ hooks: [
{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' },
{ type: 'command', command: 'node user-cleanup.js' },
] },
],
},
}));
claudeAdapter.uninstall({ configRoot: tmp });
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const remaining = (after.hooks && after.hooks.Stop)
? after.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command))
: [];
assert.ok(!remaining.some(c => c.includes('evolver-session-end')),
'evolver hook must be filtered out and the change persisted to disk');
assert.ok(remaining.includes('node user-cleanup.js'),
'user hook in same matcher must remain');
} finally { cleanup(tmp); }
});
it('buildClaudeHooks produces Claude Code HookMatcher structure', () => {
const hooks = claudeAdapter.buildClaudeHooks('/evolver');
for (const event of ['SessionStart', 'PostToolUse', 'Stop']) {
const matchers = hooks.hooks[event];
assert.ok(Array.isArray(matchers), `${event} must be an array`);
assert.ok(matchers.length > 0, `${event} must have matchers`);
for (const matcher of matchers) {
assert.ok(Array.isArray(matcher.hooks), `${event} matcher must have .hooks array`);
for (const cmd of matcher.hooks) {
assert.equal(cmd.type, 'command');
assert.equal(typeof cmd.command, 'string');
assert.ok(cmd.command.length > 0);
}
}
}
assert.equal(hooks.hooks.PostToolUse[0].matcher, 'Write');
});
});
// -- Codex adapter --
describe('codex adapter', () => {
it('installs hooks, config.toml, and AGENTS.md', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
const result = codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(result.ok, true);
assert.equal(result.platform, 'codex');
const hooks = JSON.parse(fs.readFileSync(path.join(tmp, '.codex', 'hooks.json'), 'utf8'));
assert.ok(hooks.hooks.SessionStart);
assert.ok(hooks.hooks.Stop);
const toml = fs.readFileSync(path.join(tmp, '.codex', 'config.toml'), 'utf8');
assert.ok(toml.includes('codex_hooks = true'));
assert.ok(fs.existsSync(path.join(tmp, 'AGENTS.md')));
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
assert.ok(agentsMd.includes('evolver_status'));
assert.ok(agentsMd.includes('evolver_search_assets'));
assert.ok(agentsMd.includes('evolver_publish_asset'));
assert.ok(!agentsMd.includes('For substantive tasks, call `gep_recall` before work'));
} finally { cleanup(tmp); }
});
it('ensureConfigToml adds feature flag', () => {
const tmp = makeTmpDir();
try {
const codexDir = path.join(tmp, '.codex');
fs.mkdirSync(codexDir, { recursive: true });
const changed = codexAdapter.ensureConfigToml(codexDir);
assert.equal(changed, true);
const toml = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8');
assert.ok(toml.includes('[features]'));
assert.ok(toml.includes('codex_hooks = true'));
const noChange = codexAdapter.ensureConfigToml(codexDir);
assert.equal(noChange, false);
} finally { cleanup(tmp); }
});
it('uninstalls hooks and AGENTS.md section', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = codexAdapter.uninstall({ configRoot: tmp });
assert.equal(result.ok, true);
assert.equal(result.removed, true);
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
assert.ok(!agentsMd.includes('evolver-evolution-memory'));
} finally { cleanup(tmp); }
});
it('uninstall cleans codex_hooks flag from config.toml (#538)', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
// Simulate a user adding their own [features] entry alongside evolver's.
const tomlPath = path.join(tmp, '.codex', 'config.toml');
const before = fs.readFileSync(tomlPath, 'utf8');
fs.writeFileSync(tomlPath, before + 'user_feature = true\n');
codexAdapter.uninstall({ configRoot: tmp });
const after = fs.readFileSync(tomlPath, 'utf8');
assert.ok(!after.includes('codex_hooks'), 'codex_hooks line must be removed');
assert.ok(after.includes('user_feature'), 'unrelated user feature must be preserved');
} finally { cleanup(tmp); }
});
it('uninstall removes [features] header when only codex_hooks lived there', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
codexAdapter.uninstall({ configRoot: tmp });
const after = fs.readFileSync(path.join(tmp, '.codex', 'config.toml'), 'utf8');
assert.ok(!after.includes('[features]'),
'orphan [features] block should be removed when empty');
} finally { cleanup(tmp); }
});
it('cleanConfigToml preserves [features] when user entries follow a blank line (PR #94 round-3)', () => {
// Bugbot round-3 finding: the multiline `$` in
// /\[features\]\s*\n(?=\s*\[|\s*$)/m could match a blank line
// mid-file and strand user entries below the removed header.
// Verify several layouts that previously worried Bugbot.
const tmp = makeTmpDir();
try {
const codexDir = path.join(tmp, '.codex');
fs.mkdirSync(codexDir, { recursive: true });
const tomlPath = path.join(codexDir, 'config.toml');
const layouts = [
'[features]\ncodex_hooks = true\n\nuser_feature = true\n',
'[features]\ncodex_hooks = true\n\n# user comment\nuser_feature = true\n',
'[features]\ncodex_hooks = true\nuser_feature = true\n\n[other]\nfoo = 1\n',
];
for (const layout of layouts) {
fs.writeFileSync(tomlPath, layout);
codexAdapter.cleanConfigToml(codexDir);
const after = fs.readFileSync(tomlPath, 'utf8');
assert.ok(!after.includes('codex_hooks'),
`codex_hooks must be removed (layout=${JSON.stringify(layout)})`);
assert.ok(after.includes('[features]'),
`[features] must NOT be removed when user entries remain (layout=${JSON.stringify(layout)} -> ${JSON.stringify(after)})`);
assert.ok(after.includes('user_feature'),
`user entry must be preserved (layout=${JSON.stringify(layout)})`);
}
} finally { cleanup(tmp); }
});
it('cleanConfigToml drops [features] when section becomes empty', () => {
const tmp = makeTmpDir();
try {
const codexDir = path.join(tmp, '.codex');
fs.mkdirSync(codexDir, { recursive: true });
const tomlPath = path.join(codexDir, 'config.toml');
// (input, expectedFeaturesPresent)
const layouts = [
['[features]\ncodex_hooks = true\n', false],
['[features]\ncodex_hooks = true\n\n[other]\nfoo = 1\n', false],
['[features]\ncodex_hooks = true\n[other]\nfoo = 1\n', false],
];
for (const [layout, shouldKeep] of layouts) {
fs.writeFileSync(tomlPath, layout);
codexAdapter.cleanConfigToml(codexDir);
const after = fs.readFileSync(tomlPath, 'utf8');
assert.ok(!after.includes('codex_hooks'), 'codex_hooks must be removed');
assert.equal(after.includes('[features]'), shouldKeep,
`[features] presence mismatch (layout=${JSON.stringify(layout)} -> ${JSON.stringify(after)})`);
}
} finally { cleanup(tmp); }
});
it('install preserves user-installed hooks under same event (#539)', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
const hooksJsonPath = path.join(tmp, '.codex', 'hooks.json');
fs.writeFileSync(hooksJsonPath, JSON.stringify({
hooks: {
Stop: [{ type: 'command', command: 'node my-tool.js' }],
PostToolUse: [{ type: 'command', command: 'node my-watcher.js' }],
},
}));
const evolverRoot = path.resolve(__dirname, '..');
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const merged = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
const stopCmds = merged.hooks.Stop.map(h => h.command);
assert.ok(stopCmds.includes('node my-tool.js'), 'user Stop hook must be preserved');
assert.ok(stopCmds.some(c => c.includes('evolver-session-end')), 'evolver Stop hook must be added');
const postCmds = merged.hooks.PostToolUse.map(h => h.command);
assert.ok(postCmds.includes('node my-watcher.js'));
assert.ok(postCmds.some(c => c.includes('evolver-signal-detect')));
} finally { cleanup(tmp); }
});
});
// ---------------------------------------------------------------------------
// #547: setup-hooks must copy every helper required by the entry-point hooks
//
// Two regressions of this exact shape have shipped:
// - PR #94 review caught `_runtimePaths.js` missing from the copy list.
// - Issue #547 (rendigua, v1.87.0): `_memoryFiltering.js` was added to
// evolver-session-start.js but not to hookAdapter.js's copy list, so
// fresh installs crashed immediately with
// `Error: Cannot find module './_memoryFiltering'`.
//
// This test scans every `require('./_xxx')` in the source adapter scripts
// and asserts the target file is present in `src/adapters/scripts/` AND
// that an end-to-end `installHooks` actually copies it into the
// destination dir. Any future helper added but forgotten in
// hookAdapter.js's list will trip the assertion immediately.
// ---------------------------------------------------------------------------
describe('setup-hooks copies every helper required by the entry-point scripts (#547)', () => {
const scriptsDir = path.resolve(__dirname, '..', 'src', 'adapters', 'scripts');
function collectRelativeRequires() {
const entries = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.js'));
const requires = new Set();
for (const entry of entries) {
const src = fs.readFileSync(path.join(scriptsDir, entry), 'utf8');
// Match `require('./foo')` and `require("./foo")` for any "./..."
// path that targets a sibling file (not a node_modules package).
const re = /require\(\s*['"](\.\/[^'"]+)['"]\s*\)/g;
let m;
while ((m = re.exec(src)) !== null) {
// Normalise: ensure .js suffix for matching against directory list.
let target = m[1].replace(/^\.\//, '');
if (target.includes('/')) continue; // only verify same-dir sibling helpers
if (!target.endsWith('.js')) target += '.js';
requires.add(target);
}
}
return [...requires];
}
it('every local require in adapter scripts points to a file that actually exists in scripts/', () => {
const requires = collectRelativeRequires();
assert.ok(requires.length > 0, 'expected at least one local require to verify');
const existingScripts = new Set(fs.readdirSync(scriptsDir));
for (const target of requires) {
assert.ok(existingScripts.has(target),
`adapter script requires './${target.replace(/\.js$/, '')}' but src/adapters/scripts/${target} does not exist`);
}
});
it('every required helper is copied by installHooks (Codex)', () => {
const requires = collectRelativeRequires();
const tmp = makeTmpDir();
try {
const hooksDir = path.join(tmp, '.codex', 'hooks');
const evolverRoot = path.resolve(__dirname, '..', 'src', 'adapters');
hookAdapter.copyHookScripts(hooksDir, evolverRoot);
const installed = new Set(fs.readdirSync(hooksDir));
for (const target of requires) {
assert.ok(installed.has(target),
`evolver-session-*.js requires './${target.replace(/\.js$/, '')}' but setup-hooks did not copy ` +
`src/adapters/scripts/${target} into the destination dir (this was the root cause of #547)`);
}
} finally { cleanup(tmp); }
});
it('every helper copied by install is also removed by uninstall (no orphan files)', () => {
const requires = collectRelativeRequires();
const tmp = makeTmpDir();
try {
const hooksDir = path.join(tmp, '.codex', 'hooks');
const evolverRoot = path.resolve(__dirname, '..', 'src', 'adapters');
hookAdapter.copyHookScripts(hooksDir, evolverRoot);
hookAdapter.removeHookScripts(hooksDir);
// After uninstall, none of the required-helper files should remain.
const remaining = fs.existsSync(hooksDir) ? new Set(fs.readdirSync(hooksDir)) : new Set();
for (const target of requires) {
assert.ok(!remaining.has(target),
`setup-hooks --uninstall left orphan file ${target} (install copies it but uninstall does not remove it)`);
}
} finally { cleanup(tmp); }
});
});