Ctrl K
ComunidadGuías…

tests/test_repo_local_config.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 → 'caveman'.
// Legacy level names (lite/full/ultra/wenyan*) in any source resolve to the
// skill they became.
//
// 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;
delete process.env.CAVEMAN_REINFORCE;

const { getDefaultMode, findRepoConfigPath, getReinforce } = 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 "caveman" when no env, no repo config, no user config', (tmp) => {
  process.chdir(tmp);
  assert.strictEqual(getDefaultMode(), 'caveman');
});

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(), 'caveman');
});

test('reads .caveman.json in cwd', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'),
    JSON.stringify({ defaultMode: 'ultra' }));
  process.chdir(tmp);
  assert.strictEqual(getDefaultMode(), 'ultracave');
});

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(), 'caveman');
});

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(), 'megacave');
});

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(), 'ultracave');
});

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(), 'caveman');
  } 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(), 'caveman');
});

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(), 'caveman');
});

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(), 'caveman');
});

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(), 'caveman', '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 {
    // Leave the directory before removing it — Windows EBUSYs on rmdir of cwd.
    process.chdir(tmp);
    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(), 'caveman');
  assert.strictEqual(getDefaultMode(undefined), 'caveman');
});

test('new mode ids are accepted from every source', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'megacave' }));
  process.chdir(tmp);
  assert.strictEqual(getDefaultMode(), 'megacave');
  process.env.CAVEMAN_DEFAULT_MODE = 'ultracave';
  assert.strictEqual(getDefaultMode(), 'ultracave');
  process.env.CAVEMAN_DEFAULT_MODE = 'manual';
  assert.strictEqual(getDefaultMode(), 'manual', 'manual stays a default-policy value');
});

// C37: hand-edited config files. PowerShell 5.1's `>` writes UTF-16LE with a
// BOM, `Set-Content -Encoding UTF8` a UTF-8 BOM; comments and trailing commas
// are what installer/lib/settings.js already tolerates in settings.json.
test('a BOM, UTF-16LE, a comment or a trailing comma does not drop defaultMode', (tmp) => {
  const json = '{"defaultMode":"off"}';
  const variants = {
    'UTF-8 BOM': Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(json)]),
    'UTF-16LE BOM': Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(json, 'utf16le')]),
    'line comment': '// mine\n{"defaultMode":"off"} // trailing',
    'block comment': '/* mine */ {"defaultMode": /* why */ "off"}',
    'trailing comma': '{\n  "defaultMode": "off",\n}',
    'comment-like string': '{"note": "http://x // y,}", "defaultMode": "off",}',
  };
  fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
  process.chdir(tmp);
  try {
    for (const [name, content] of Object.entries(variants)) {
      fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'), content);
      assert.strictEqual(getDefaultMode(), 'off', `user config with ${name}`);
      fs.writeFileSync(path.join(tmp, '.caveman.json'), content);
      assert.strictEqual(getDefaultMode(), 'off', `.caveman.json with ${name}`);
      fs.rmSync(path.join(tmp, '.caveman.json'));
    }
  } finally {
    fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
  }
});

// C38: the docs give ~/.config/caveman/config.json for every platform. Windows
// reads %APPDATA%\caveman\config.json first, then that path.
test('Windows reads %APPDATA% then ~/.config for the user config', (tmp) => {
  const saved = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, APPDATA: process.env.APPDATA,
    HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
  const platform = Object.getOwnPropertyDescriptor(process, 'platform');
  delete process.env.XDG_CONFIG_HOME;
  process.env.APPDATA = path.join(tmp, 'appdata');
  process.env.HOME = process.env.USERPROFILE = path.join(tmp, 'home');
  Object.defineProperty(process, 'platform', { value: 'win32' });
  try {
    process.chdir(tmp);
    fs.mkdirSync(path.join(tmp, 'home', '.config', 'caveman'), { recursive: true });
    fs.writeFileSync(path.join(tmp, 'home', '.config', 'caveman', 'config.json'), '{"defaultMode":"off"}');
    assert.strictEqual(getDefaultMode(), 'off', '~/.config is read when %APPDATA% has none');
    fs.mkdirSync(path.join(tmp, 'appdata', 'caveman'), { recursive: true });
    fs.writeFileSync(path.join(tmp, 'appdata', 'caveman', 'config.json'), '{"defaultMode":"ultra"}');
    assert.strictEqual(getDefaultMode(), 'ultracave', '%APPDATA% wins when both name a mode');
  } finally {
    Object.defineProperty(process, 'platform', platform);
    for (const [k, v] of Object.entries(saved)) {
      if (v === undefined) delete process.env[k]; else process.env[k] = v;
    }
  }
});

// C39: .caveman/config.json is also the CLI's project overlay, so it often
// holds no defaultMode. It must not hide a sibling .caveman.json or a parent's.
test('a .caveman/config.json without defaultMode does not hide .caveman.json', (tmp) => {
  fs.mkdirSync(path.join(tmp, '.caveman'));
  fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), JSON.stringify({ execute: { mcp: false } }));
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'off' }));
  const nested = path.join(tmp, 'sub');
  fs.mkdirSync(path.join(nested, '.caveman'), { recursive: true });
  fs.writeFileSync(path.join(nested, '.caveman', 'config.json'), '{}');
  process.chdir(tmp);
  assert.strictEqual(getDefaultMode(), 'off', 'sibling .caveman.json');
  assert.strictEqual(getDefaultMode(nested), 'off', 'parent directory');
});

// #1468: the per-turn reinforcement switch resolves through the same sources,
// in the same order, as defaultMode: env → nearest repo config naming it →
// user config → on.
function withReinforceEnv(value, fn) {
  const saved = process.env.CAVEMAN_REINFORCE;
  if (value === undefined) delete process.env.CAVEMAN_REINFORCE;
  else process.env.CAVEMAN_REINFORCE = value;
  try { fn(); } finally {
    if (saved === undefined) delete process.env.CAVEMAN_REINFORCE;
    else process.env.CAVEMAN_REINFORCE = saved;
  }
}

function withUserConfig(content, fn) {
  fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
  fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'), content);
  try { fn(); } finally {
    fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
  }
}

test('reinforcement is on when nothing configures it', (tmp) => {
  process.chdir(tmp);
  assert.strictEqual(getReinforce(), true);
});

test('repo config reinforce:false turns it off, from a nested dir and via startDir', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: false }));
  const nested = path.join(tmp, 'a', 'b');
  fs.mkdirSync(nested, { recursive: true });
  process.chdir(nested);
  assert.strictEqual(getReinforce(), false, 'walk up from cwd');
  process.chdir(os.tmpdir());
  assert.strictEqual(getReinforce(nested), false, 'startDir');
});

test('CAVEMAN_REINFORCE beats repo config, both ways', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: false }));
  process.chdir(tmp);
  withReinforceEnv('1', () => assert.strictEqual(getReinforce(), true));
  withReinforceEnv('true', () => assert.strictEqual(getReinforce(), true));
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: true }));
  for (const off of ['0', 'false', 'off', 'no', ' FALSE ']) {
    withReinforceEnv(off, () => assert.strictEqual(getReinforce(), false, off));
  }
});

test('an unrecognized CAVEMAN_REINFORCE falls through to config', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: false }));
  process.chdir(tmp);
  withReinforceEnv('maybe', () => assert.strictEqual(getReinforce(), false));
  withReinforceEnv('', () => assert.strictEqual(getReinforce(), false));
});

test('repo reinforce beats user reinforce; user config applies without one', (tmp) => {
  withUserConfig(JSON.stringify({ reinforce: false }), () => {
    process.chdir(tmp);
    assert.strictEqual(getReinforce(), false, 'user config alone');
    fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: true }));
    assert.strictEqual(getReinforce(), true, 'repo config wins');
  });
});

test('a repo config naming only defaultMode does not hide the user reinforce', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ defaultMode: 'ultracave' }));
  withUserConfig('{\n  // comments are fine\n  "reinforce": false,\n}', () => {
    process.chdir(tmp);
    assert.strictEqual(getReinforce(), false);
    assert.strictEqual(getDefaultMode(), 'ultracave', 'defaultMode unaffected');
  });
});

test('a non-boolean reinforce value is ignored', (tmp) => {
  fs.writeFileSync(path.join(tmp, '.caveman.json'), JSON.stringify({ reinforce: 'sometimes' }));
  process.chdir(tmp);
  assert.strictEqual(getReinforce(), true);
});

console.log(`\n${passed} passed, ${failed} failed`);
fs.rmSync(tmpHome, { recursive: true, force: true });
process.exit(failed === 0 ? 0 : 1);