mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
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.
This commit is contained in:
parent
28b922dee3
commit
5debb798c8
4 changed files with 318 additions and 7 deletions
290
tests/hooks/detect-project-nongit.test.js
Normal file
290
tests/hooks/detect-project-nongit.test.js
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue