// tests/lib/review-recurrence-check-ignore.test.mjs // CKI (fix round 4.5b, part 2). `git check-ignore` exits 128 for "not a git // repository" AND for other failures inside a repository (a path beyond a // symbolic link, a broken .git/HEAD, a GIT_DIR that does not exist). The // lessons pointer in CLAUDE.md may only be written when the directory is truly // no repository; every other 128 is a failed check (`check-failed`, no // pointer). The answer must not depend on the language git speaks. // // Everything runs through the CLI as a child process, so this file loads at // the base. The git environment is fixed here: an empty global config, no // system config, a ceiling at the sandbox and the C locale, so a global // core.excludesFile, the temp directory's location or a translated git cannot // change the result. Every fixture lives under the one sandbox this test makes. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, realpathSync, accessSync, statSync, constants, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, delimiter } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from '../helpers/child-env.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs'); const POINTER_START = ''; const CLAUDE_TEXT = '# Project\n'; // ---- review fixtures (copied from tests/lib/review-recurrence.test.mjs:23-84) ---- function reviewText({ slug, sha, findings }) { const json = JSON.stringify({ verdict: 'WARN', counts: { BLOCKER: 0, MAJOR: findings.length, MINOR: 0, SUGGESTION: 0 }, findings, }, null, 2); return `--- type: trekreview review_version: "1.1" task: "${slug}" slug: ${slug} project_dir: .claude/projects/2026-10-0${sha.length % 9}-${slug}/ brief_path: .claude/projects/x/brief.md scope_sha_end: ${sha} reviewed_files_count: 1 verdict: WARN findings: ${findings.map((f) => ` - ${f.id}`).join('\n')} --- # Review: ${slug} ## Executive Summary x ## Coverage x ## Remediation Summary x \`\`\`json ${json} \`\`\` `; } function finding(id, rule_key, extra = {}) { return { id: id.padEnd(40, '0'), severity: 'MAJOR', rule_key, file: 'lib/a.mjs', line: 3, title: `${rule_key} title ${id}`, detail: 'd', recommended_action: `Fix it (${id}).`, ...extra, }; } /** The two recurring MISSING_TEST reviews written as `/projects/...`. */ function reviewsInto(claudeDir) { const reviews = [ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]; reviews.forEach((r, i) => { const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`); mkdirSync(p, { recursive: true }); writeFileSync(join(p, 'review.md'), reviewText(r)); }); } // ---- the sandbox and its git environment ---- /** Where the current PATH finds `name`, as an absolute path. */ function onPath(name) { for (const dir of String(process.env.PATH ?? '').split(delimiter).filter(Boolean)) { const p = join(dir, name); try { accessSync(p, constants.X_OK); if (statSync(p).isFile()) return p; } catch { /* not here */ } } throw new Error(`${name} is not on PATH`); } function makeSandbox() { const base = realpathSync(mkdtempSync(join(tmpdir(), 'recurrence-cki-'))); const gitConfig = join(base, 'empty-gitconfig'); writeFileSync(gitConfig, ''); const home = join(base, 'home'); mkdirSync(home); const realGit = onPath('git'); // resolved before any stub dir is put first on PATH const env = (over = {}) => childEnv({ PATH: process.env.PATH ?? '', HOME: home, XDG_CONFIG_HOME: home, GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1', GIT_CEILING_DIRECTORIES: base, LC_ALL: 'C', LANG: 'C', LANGUAGE: undefined, GIT_DIR: undefined, GIT_WORK_TREE: undefined, ...over, }); const git = (args) => { const r = spawnSync('git', args, { encoding: 'utf-8', env: env() }); assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); }; /** A directory under the sandbox holding the two reviews in `.claude` and a plain CLAUDE.md. */ const plainDir = (name) => { const dir = join(base, name); mkdirSync(dir, { recursive: true }); reviewsInto(join(dir, '.claude')); writeFileSync(join(dir, 'CLAUDE.md'), CLAUDE_TEXT); return dir; }; const repo = (name) => { const dir = plainDir(name); git(['-C', dir, 'init', '-q']); return dir; }; /** A PATH whose first `git` fails only `check-ignore` (exit 128, `message`) and execs the real git otherwise. */ const stubPath = (name, message) => { const bin = join(base, name); mkdirSync(bin); if (realGit.includes("'") || message.includes("'")) throw new Error('a quote in the stub text'); writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor a in "$@"; do\n if [ "$a" = "check-ignore" ]; then\n echo '${message}' >&2\n exit 128\n fi\ndone\nexec '${realGit}' "$@"\n`, { mode: 0o755 }); return `${bin}${delimiter}${process.env.PATH ?? ''}`; }; return { base, env, git, plainDir, repo, stubPath, cleanup: () => rmSync(base, { recursive: true, force: true }) }; } function apply(sb, dir, over = {}) { const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', dir, '--rule-key', 'MISSING_TEST'], { encoding: 'utf-8', env: sb.env(over), }); assert.equal(r.status, 0, `the lesson itself is written (exit 0): ${r.stdout}\n${r.stderr}`); const out = JSON.parse(r.stdout); assert.equal(out.written, true, r.stdout); return out; } function assertNoPointer(out, dir, label) { assert.equal(out.pointer, 'check-failed', `${label}: ${JSON.stringify(out)}`); assert.equal(typeof out.pointer_note, 'string', `${label}: check-failed carries a note: ${JSON.stringify(out)}`); assert.match(out.pointer_note, /check-ignore/, `${label}: ${JSON.stringify(out)}`); assert.equal(readFileSync(join(dir, 'CLAUDE.md'), 'utf-8'), CLAUDE_TEXT, `${label}: CLAUDE.md is byte-unchanged`); } function assertPointer(out, dir, label) { assert.equal(out.pointer, 'written', `${label}: ${JSON.stringify(out)}`); assert.ok(readFileSync(join(dir, 'CLAUDE.md'), 'utf-8').includes(POINTER_START), `${label}: CLAUDE.md holds the pointer`); } const ENGLISH_NOT_A_REPO = 'fatal: not a git repository (or any of the parent directories): .git'; // Simulates a translated git: the same "not a git repository" message in German (ASCII only). const GERMAN_NOT_A_REPO = 'fatal: Kein Git-Repository (oder irgendeines der Elternverzeichnisse): .git'; test('[CKI] a git check-ignore exit 128 inside a repository writes no pointer, in any language', () => { const sb = makeSandbox(); try { const ceilingMix = ['', join(sb.base, 'no-such-ceiling'), sb.base].join(delimiter); // 1. Red: `.claude` is a relative symlink to an in-repo directory; check-ignore // exits 128 "beyond a symbolic link" inside a real repository. const linked = join(sb.base, 'linked'); mkdirSync(linked); sb.git(['-C', linked, 'init', '-q']); reviewsInto(join(linked, 'claude-data')); symlinkSync('claude-data', join(linked, '.claude')); writeFileSync(join(linked, 'CLAUDE.md'), CLAUDE_TEXT); assertNoPointer(apply(sb, linked), linked, 'case 1 (symlinked .claude)'); // 2. Red: a real repository, and a git whose check-ignore alone fakes the // English "not a git repository" with exit 128. const faked = sb.repo('faked'); assertNoPointer(apply(sb, faked, { PATH: sb.stubPath('stub-en', ENGLISH_NOT_A_REPO) }), faked, 'case 2 (stub check-ignore 128 in a real repo)'); // 3. Red: a repository whose .git/HEAD holds garbage; check-ignore and // rev-parse both exit 128, so only the .git-entry walk can tell. const broken = sb.repo('broken'); writeFileSync(join(broken, '.git', 'HEAD'), 'garbage\n'); assertNoPointer(apply(sb, broken), broken, 'case 3 (garbage .git/HEAD)'); // 4. Control: a plain directory under the ceiling (no repository) gets its // pointer; the ceiling list holds an empty entry and a missing path too. const plain = sb.plainDir('plain'); assertPointer(apply(sb, plain, { GIT_CEILING_DIRECTORIES: ceilingMix }), plain, 'case 4 (plain directory)'); // 5. Control: as case 4, with a git that says "not a git repository" in another language. const plainDe = sb.plainDir('plain-de'); assertPointer(apply(sb, plainDe, { GIT_CEILING_DIRECTORIES: ceilingMix, PATH: sb.stubPath('stub-de', GERMAN_NOT_A_REPO), }), plainDe, 'case 5 (translated git, plain directory)'); // 6. Control: the target is a plain directory inside a repository that sits // at the ceiling; git does not look into the ceiling, nor may the walk. const outer = sb.repo('outer'); const inner = sb.plainDir(join('outer', 'inner')); assertPointer(apply(sb, inner, { GIT_CEILING_DIRECTORIES: realpathSync(outer) }), inner, 'case 6 (repository at the ceiling)'); // 7. Red: as case 4, with GIT_DIR set to a path that does not exist. const plainGitDir = sb.plainDir('plain-git-dir'); const out7 = apply(sb, plainGitDir, { GIT_CEILING_DIRECTORIES: ceilingMix, GIT_DIR: join(sb.base, 'no-such-git-dir'), }); assertNoPointer(out7, plainGitDir, 'case 7 (GIT_DIR set)'); assert.match(out7.pointer_note, /GIT_DIR is set/, JSON.stringify(out7)); // 8. Red: case 3's garbage-HEAD repository with a ceiling entry equal to the // repository itself (it stops nothing) plus the sandbox. const brokenSelf = sb.repo('broken-self'); writeFileSync(join(brokenSelf, '.git', 'HEAD'), 'garbage\n'); assertNoPointer(apply(sb, brokenSelf, { GIT_CEILING_DIRECTORIES: [realpathSync(brokenSelf), sb.base].join(delimiter), }), brokenSelf, 'case 8 (ceiling equal to the repository)'); } finally { sb.cleanup(); } });