Files
ECC/tests/hooks/detect-project-nongit.test.js
Gaurav DubeyandGitHub 5debb798c8 fix(continuous-learning-v2): honor non-git CLAUDE_PROJECT_DIR (#2488)
Honor an explicit non-git CLAUDE_PROJECT_DIR as an isolated project scope, canonicalize and hash it consistently in the shell observer and Python CLI, and preserve the global fallback for arbitrary non-git working directories.
2026-07-26 00:04:35 -07:00

291 lines
9.5 KiB
JavaScript

/**
* Tests for non-git CLAUDE_PROJECT_DIR project detection (issue #2469)
*
* Validates that detect-project.sh honors an explicitly-provided
* CLAUDE_PROJECT_DIR that is NOT a git repository, deriving a stable
* path-based PROJECT_ID instead of collapsing to the shared `global`
* bucket. A bare non-git cwd (no CLAUDE_PROJECT_DIR) must still fall
* back to `global` — the fix is gated on the explicit env var so an
* arbitrary working directory never becomes a "project".
*
* Run with: node tests/hooks/detect-project-nongit.test.js
*/
// Skip on Windows — these tests invoke bash scripts directly
if (process.platform === 'win32') {
console.log('Skipping bash-dependent non-git detection tests on Windows\n');
process.exit(0);
}
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFileSync, spawnSync } = require('child_process');
// Locate a Python interpreter for the cross-implementation consistency check.
// Absent Python just skips that one case (the bash path is the primary fix).
function findPython() {
for (const bin of ['python3', 'python']) {
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
if (!r.error && r.status === 0) return bin;
}
return null;
}
const PYTHON = findPython();
let passed = 0;
let failed = 0;
let skipped = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (err) {
console.log(` ✗ ${name}`);
console.log(` Error: ${err.message}`);
failed++;
}
}
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nongit-test-'));
}
function cleanupDir(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
const repoRoot = path.resolve(__dirname, '..', '..');
const detectProjectPath = path.join(
repoRoot,
'skills',
'continuous-learning-v2',
'scripts',
'detect-project.sh'
);
const instinctCliPath = path.join(
repoRoot,
'skills',
'continuous-learning-v2',
'scripts',
'instinct-cli.py'
);
// Resolve the project id the Python CLI (instinct-cli.py) assigns, so we can
// prove it agrees with the shell observer for the same non-git directory.
function pythonProjectId(projectDir, homeDir) {
const code =
'import importlib.util as u, sys;' +
`s=u.spec_from_file_location("icli", ${JSON.stringify(instinctCliPath)});` +
'm=u.module_from_spec(s); s.loader.exec_module(m);' +
'print(m.detect_project()["id"])';
const r = spawnSync(PYTHON, ['-c', code], {
cwd: projectDir,
timeout: 10000,
encoding: 'utf8',
env: {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
CLAUDE_PROJECT_DIR: projectDir,
},
});
if (r.status !== 0) {
throw new Error(`instinct-cli.py detect_project failed: ${r.stderr || r.error}`);
}
return r.stdout.trim();
}
// Source detect-project.sh with an isolated HOME and the given
// CLAUDE_PROJECT_DIR, returning the exported PROJECT_* vars.
function detect(projectDir, homeDir, { setProjectDir = true } = {}) {
const env = {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
};
if (setProjectDir) {
env.CLAUDE_PROJECT_DIR = projectDir;
} else {
delete env.CLAUDE_PROJECT_DIR;
}
// Suppress only stdout; keep stderr so a sourcing failure (e.g. a syntax
// error or missing _CLV2_PYTHON_CMD) surfaces instead of silently leaving
// the PROJECT_* vars empty and producing confusing assertion messages.
const script = `
source "${detectProjectPath}" >/dev/null
printf 'PROJECT_ID=%s\\n' "$PROJECT_ID"
printf 'PROJECT_NAME=%s\\n' "$PROJECT_NAME"
printf 'PROJECT_ROOT=%s\\n' "$PROJECT_ROOT"
`;
const out = execFileSync('bash', ['-lc', script], {
// Run from the project dir; it is not a git repo, so the cwd-git
// branch (priority 2) cannot fire and interfere with the assertions.
cwd: projectDir,
timeout: 10000,
env,
}).toString();
const vars = {};
for (const line of out.trim().split('\n')) {
const m = line.match(/^(PROJECT_ID|PROJECT_NAME|PROJECT_ROOT)=(.*)$/);
if (m) vars[m[1]] = m[2];
}
return vars;
}
console.log('\n=== Non-git CLAUDE_PROJECT_DIR detection (issue #2469) ===\n');
console.log('--- Content check ---');
test('detect-project.sh has an env-nogit fallback for non-git dirs', () => {
const content = fs.readFileSync(detectProjectPath, 'utf8');
assert.ok(
content.includes('env-nogit'),
'detect-project.sh should set source_hint="env-nogit" for non-git CLAUDE_PROJECT_DIR'
);
});
console.log('\n--- Behavior: non-git CLAUDE_PROJECT_DIR ---');
test('non-git CLAUDE_PROJECT_DIR yields a non-global, path-derived PROJECT_ID', () => {
const testDir = createTempDir();
try {
const homeDir = path.join(testDir, 'home');
const projectDir = path.join(testDir, 'my-plain-project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
assert.ok(!fs.existsSync(path.join(projectDir, '.git')), 'guard: project dir must not be a git repo');
const vars = detect(projectDir, homeDir);
assert.ok(
vars.PROJECT_ID && vars.PROJECT_ID !== 'global',
`PROJECT_ID should not be "global", got: "${vars.PROJECT_ID || ''}"`
);
assert.ok(
/^[0-9a-f]{12}$/.test(vars.PROJECT_ID),
`PROJECT_ID should be a 12-char hex hash, got: "${vars.PROJECT_ID || ''}"`
);
assert.strictEqual(
vars.PROJECT_NAME,
'my-plain-project',
`PROJECT_NAME should be the directory basename, got: "${vars.PROJECT_NAME || ''}"`
);
assert.strictEqual(
vars.PROJECT_ROOT,
fs.realpathSync(projectDir),
`PROJECT_ROOT should be the canonicalized project dir, got: "${vars.PROJECT_ROOT || ''}"`
);
} finally {
cleanupDir(testDir);
}
});
test('PROJECT_ID is stable across repeated invocations of the same dir', () => {
const testDir = createTempDir();
try {
const homeDir = path.join(testDir, 'home');
const projectDir = path.join(testDir, 'stable-project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
const first = detect(projectDir, homeDir).PROJECT_ID;
const second = detect(projectDir, homeDir).PROJECT_ID;
assert.ok(first && first !== 'global', 'first run should produce a real id');
assert.strictEqual(second, first, 'the same non-git dir must hash to the same PROJECT_ID');
} finally {
cleanupDir(testDir);
}
});
test('distinct non-git dirs produce distinct PROJECT_IDs', () => {
const testDir = createTempDir();
try {
const homeDir = path.join(testDir, 'home');
const dirA = path.join(testDir, 'project-a');
const dirB = path.join(testDir, 'project-b');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(dirA, { recursive: true });
fs.mkdirSync(dirB, { recursive: true });
const idA = detect(dirA, homeDir).PROJECT_ID;
const idB = detect(dirB, homeDir).PROJECT_ID;
assert.ok(idA && idA !== 'global' && idB && idB !== 'global', 'both dirs should get real ids');
assert.notStrictEqual(idA, idB, 'different non-git dirs must not share a project id');
} finally {
cleanupDir(testDir);
}
});
console.log('\n--- Gating: bare non-git cwd stays global ---');
test('non-git cwd with no CLAUDE_PROJECT_DIR still falls back to global', () => {
const testDir = createTempDir();
try {
const homeDir = path.join(testDir, 'home');
const projectDir = path.join(testDir, 'unregistered');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
const vars = detect(projectDir, homeDir, { setProjectDir: false });
assert.strictEqual(
vars.PROJECT_ID,
'global',
`without CLAUDE_PROJECT_DIR an arbitrary non-git cwd must stay "global", got: "${vars.PROJECT_ID || ''}"`
);
} finally {
cleanupDir(testDir);
}
});
console.log('\n--- Cross-impl consistency: shell observer vs Python CLI ---');
// Guard the Python-dependent case OUTSIDE test() so an unmet prerequisite is
// counted as skipped, never as a silent pass. This is not a hard failure:
// detect-project.sh itself degrades gracefully without Python (it falls back to
// shasum/sha256sum), so a Python-less host is a supported environment where the
// cross-check simply cannot run.
if (!PYTHON) {
console.log(' ⊘ instinct-cli.py cross-impl check (skipped — no Python interpreter available)');
skipped++;
} else {
test('instinct-cli.py assigns the same non-global id as detect-project.sh', () => {
const testDir = createTempDir();
try {
const homeDir = path.join(testDir, 'home');
const projectDir = path.join(testDir, 'shared-nongit-project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
const shellId = detect(projectDir, homeDir).PROJECT_ID;
const pyId = pythonProjectId(projectDir, homeDir);
assert.ok(shellId && shellId !== 'global', `shell id should be real, got "${shellId}"`);
assert.ok(pyId && pyId !== 'global', `python id should be real, got "${pyId}"`);
assert.strictEqual(
pyId,
shellId,
`observer (${shellId}) and CLI (${pyId}) must agree so observations/instincts stay grouped`
);
} finally {
cleanupDir(testDir);
}
});
}
console.log('\n=== Test Results ===');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Skipped: ${skipped}`);
console.log(`Total: ${passed + failed + skipped}\n`);
process.exit(failed > 0 ? 1 : 0);