Files
evomap__evolver/test/resolveProjectDir.test.js
autogame-17 39c0d3b8a4 fix(hooks): resolve project dir from host env, not process.cwd() (#554)
Cursor invokes hook events (e.g. afterFileEdit) with the working
directory set to the plugin install dir, not the opened workspace.
evolver-session-end.js ran git diff against process.cwd() and therefore
found no changes inside the plugin dir, silently recording nothing for
every Cursor task.

Add resolveProjectDir() to _runtimePaths.js: prefer CURSOR_PROJECT_DIR,
then CLAUDE_PROJECT_DIR (Claude Code, and Cursor compat alias), then fall
back to process.cwd(). Only honor an env value that points at an existing
directory so a stale value cannot redirect git collection. Codex,
opencode, Kiro and direct CLI usage leave both env vars unset, so cwd
remains the source there — a no-op on those platforms.

getGitDiffStats() now uses resolveProjectDir(). The workspace-tag cwd in
recordToLocal() is intentionally left on process.cwd(): the review-time
reader uses it for backward-compat scoping while the forge-resistant tag
is workspace_id, so changing the writer there would risk a silent
reader/writer mismatch.

Tests: new test/resolveProjectDir.test.js (priority order, stale/empty/
file env values) plus two session-end regressions proving a diff is
recorded via CURSOR_PROJECT_DIR / CLAUDE_PROJECT_DIR when cwd is wrong.

Co-authored-by: autogame-17 <autogame-17@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:50:30 +08:00

108 lines
3.7 KiB
JavaScript

const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
// resolveProjectDir lives in the hook runtime helper. It decides which
// directory the session-end hook runs `git diff` in. The bug it fixes:
// Cursor invokes hooks with cwd set to the plugin install dir, so a hook
// that trusted process.cwd() found no changes and silently recorded nothing.
const { resolveProjectDir } = require('../src/adapters/scripts/_runtimePaths');
function makeTmpDir() {
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-projdir-test-')));
}
function cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('resolveProjectDir', () => {
// Snapshot and restore the env vars + cwd we mutate, so tests are isolated
// and we never leak a chdir into sibling suites.
let saved;
const origCwd = process.cwd();
beforeEach(() => {
saved = {
CURSOR_PROJECT_DIR: process.env.CURSOR_PROJECT_DIR,
CLAUDE_PROJECT_DIR: process.env.CLAUDE_PROJECT_DIR,
};
delete process.env.CURSOR_PROJECT_DIR;
delete process.env.CLAUDE_PROJECT_DIR;
});
afterEach(() => {
for (const k of ['CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR']) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
try { process.chdir(origCwd); } catch {}
});
it('falls back to process.cwd() when no host env var is set (Codex / opencode / Kiro / CLI)', () => {
const tmp = makeTmpDir();
try {
process.chdir(tmp);
assert.equal(resolveProjectDir(), tmp);
} finally { cleanup(tmp); }
});
it('honors CURSOR_PROJECT_DIR over cwd (Cursor: cwd is the plugin dir)', () => {
const project = makeTmpDir();
const pluginCwd = makeTmpDir();
try {
process.chdir(pluginCwd); // simulate Cursor's plugin-dir cwd
process.env.CURSOR_PROJECT_DIR = project;
assert.equal(resolveProjectDir(), project);
} finally { cleanup(project); cleanup(pluginCwd); }
});
it('honors CLAUDE_PROJECT_DIR (Claude Code, and Cursor compat alias)', () => {
const project = makeTmpDir();
const otherCwd = makeTmpDir();
try {
process.chdir(otherCwd);
process.env.CLAUDE_PROJECT_DIR = project;
assert.equal(resolveProjectDir(), project);
} finally { cleanup(project); cleanup(otherCwd); }
});
it('prefers CURSOR_PROJECT_DIR when both are set', () => {
const cursorDir = makeTmpDir();
const claudeDir = makeTmpDir();
try {
process.env.CURSOR_PROJECT_DIR = cursorDir;
process.env.CLAUDE_PROJECT_DIR = claudeDir;
assert.equal(resolveProjectDir(), cursorDir);
} finally { cleanup(cursorDir); cleanup(claudeDir); }
});
it('ignores a stale env value pointing at a non-existent dir and falls back to cwd', () => {
const tmp = makeTmpDir();
try {
process.chdir(tmp);
process.env.CURSOR_PROJECT_DIR = path.join(tmp, 'does-not-exist');
assert.equal(resolveProjectDir(), tmp);
} finally { cleanup(tmp); }
});
it('ignores an env value pointing at a file (not a directory)', () => {
const tmp = makeTmpDir();
try {
process.chdir(tmp);
const f = path.join(tmp, 'afile');
fs.writeFileSync(f, 'x');
process.env.CLAUDE_PROJECT_DIR = f;
assert.equal(resolveProjectDir(), tmp);
} finally { cleanup(tmp); }
});
it('ignores an empty / whitespace env value', () => {
const tmp = makeTmpDir();
try {
process.chdir(tmp);
process.env.CURSOR_PROJECT_DIR = ' ';
assert.equal(resolveProjectDir(), tmp);
} finally { cleanup(tmp); }
});
});