mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-21 15:02:23 +02:00
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.
291 lines
9.5 KiB
JavaScript
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);
|