Files
caveman/tests/test_repo_local_config.js
T
Julius BrusseeandClaude Fable 5 7693a046ee fix(hooks): shared parser, envelope unwrap, resume-safe SessionStart
- extract mode parsing into src/hooks/caveman-parse.js, consumed by both
  the Claude tracker and the opencode plugin — fixes the three #602 drifts
  (brevity triggers missing, bogus level overwrote flag with default,
  independent modes unreachable via expanded templates)
- unwrap Claude Code's <command-name>/<command-args> slash envelope: real
  slash-UI /caveman <level> and /caveman off were silent no-ops (#537);
  foreign envelopes skip natural-language detection entirely
- SessionStart branches on payload source: startup resets to configured
  default, resume/clear/compact preserve a valid existing flag (#691)
- scheduled-task prompts (<scheduled-task marker) skip flag mutation and
  reinforcement so unattended runs aren't hijacked
- per-turn reinforcement honors repo-local defaultMode off via
  getDefaultMode(cwd) gate — read-only, never deletes the shared flag
  (#634; rejects #532's cross-session flag deletion)
- reinforcement anchor shrunk ~57%, opencode line kept identical (#660)
- statusline setup nudge shown once, gated by .caveman-nudge-shown (#661)
- /caveman-stats delivered via hookSpecificOutput.additionalContext so the
  macOS desktop app renders it (#618)
- safeWriteFlag: retry rename on Windows sharing violations, always unlink
  temp in finally — no more .caveman-active.<pid>.<ts> litter (#511 #578)
- statusline.sh exits 0 on empty suffix file — non-zero exit was hiding
  the whole status bar (#711)
- cavecrew-model-overrides resolves plugin root across layouts; env model
  overrides were a silent no-op (#645)
- opencode dev-tree loader: base require on the loaded file so
  caveman-parse's relative require resolves in both layouts

Supersedes PRs #623 #674 #700 #691 #634 #660 #661 #692 #632 #622 #657
#578 #511 #645 #590 #498 #501 with local implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:00:43 +02:00

183 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
// Tests for repo-local config resolution in getDefaultMode().
// Covers the resolution-order contract:
// env CAVEMAN_DEFAULT_MODE → repo-local (.caveman/config.json or .caveman.json,
// walking up to filesystem root) → user config → 'full'.
//
// Run: node tests/test_repo_local_config.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
// Isolate from the host's real user config: point XDG_CONFIG_HOME at a tmp dir
// before requiring the module so getConfigPath() never reads the developer's
// own ~/.config/caveman/config.json.
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-userhome-'));
process.env.XDG_CONFIG_HOME = tmpHome;
delete process.env.CAVEMAN_DEFAULT_MODE;
const { getDefaultMode, findRepoConfigPath } = require('../src/hooks/caveman-config');
let passed = 0;
let failed = 0;
function test(name, fn) {
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-repocfg-'));
const origCwd = process.cwd();
const origEnv = process.env.CAVEMAN_DEFAULT_MODE;
try {
fn(tmpBase);
passed++;
console.log(` ✓ ${name}`);
} catch (e) {
failed++;
console.error(` ✗ ${name}`);
console.error(` ${e.message}`);
} finally {
process.chdir(origCwd);
if (origEnv === undefined) delete process.env.CAVEMAN_DEFAULT_MODE;
else process.env.CAVEMAN_DEFAULT_MODE = origEnv;
fs.rmSync(tmpBase, { recursive: true, force: true });
}
}
console.log('repo-local config resolution tests\n');
test('returns "full" when no env, no repo config, no user config', (tmp) => {
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('reads .caveman/config.json in cwd', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
});
test('reads .caveman.json in cwd', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'ultra' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'ultra');
});
test('.caveman/config.json wins over .caveman.json at same level', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'ultra' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
});
test('walks up from nested cwd to find repo config', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'wenyan-lite' }));
const nested = path.join(tmp, 'a', 'b', 'c');
fs.mkdirSync(nested, { recursive: true });
process.chdir(nested);
assert.strictEqual(getDefaultMode(), 'wenyan-lite');
});
test('env var beats repo-local config', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
process.env.CAVEMAN_DEFAULT_MODE = 'ultra';
assert.strictEqual(getDefaultMode(), 'ultra');
});
test('repo-local config beats user config', (tmp) => {
// user config at XDG_CONFIG_HOME points to 'commit'
fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'),
JSON.stringify({ defaultMode: 'commit' }));
// repo-local points to 'lite'
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
try {
assert.strictEqual(getDefaultMode(), 'lite');
} finally {
fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
}
});
test('falls through to user config when repo config absent', (tmp) => {
fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'),
JSON.stringify({ defaultMode: 'review' }));
process.chdir(tmp);
try {
assert.strictEqual(getDefaultMode(), 'review');
} finally {
fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
}
});
test('invalid mode in repo config falls through to default', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'definitely-not-a-mode' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('malformed JSON in repo config falls through to default', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), '{ not json');
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('refuses symlinked .caveman.json (symmetric with readFlag policy)', (tmp) => {
const real = path.join(tmp, 'real-config.json');
fs.writeFileSync(real, JSON.stringify({ defaultMode: 'ultra' }));
try {
fs.symlinkSync(real, path.join(tmp, '.caveman.json'));
} catch (e) {
// Skip on platforms without symlink perms
console.log(' (skipped: symlink not permitted)');
return;
}
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('findRepoConfigPath returns null outside any repo', (tmp) => {
process.chdir(tmp);
assert.strictEqual(findRepoConfigPath(tmp), null);
});
// ── #634: optional startDir param (backward compatible) ────────────────────
test('getDefaultMode(startDir) resolves repo config for a directory other than process.cwd()', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'off' }));
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-elsewhere-'));
try {
process.chdir(elsewhere); // process cwd has no repo config
assert.strictEqual(getDefaultMode(), 'full', 'process cwd alone should not see the other dir\'s config');
assert.strictEqual(getDefaultMode(tmp), 'off', 'startDir should resolve that directory\'s repo config');
} finally {
fs.rmSync(elsewhere, { recursive: true, force: true });
}
});
test('getDefaultMode() with no args is unchanged (defaults to process.cwd())', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
assert.strictEqual(getDefaultMode(undefined), 'lite');
});
console.log(`\n${passed} passed, ${failed} failed`);
fs.rmSync(tmpHome, { recursive: true, force: true });
process.exit(failed === 0 ? 0 : 1);