fix(review): a failed git check-ignore writes no pointer, whatever language git speaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-28 15:46:43 +01:00
commit 1b9d5fd459
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 314 additions and 5 deletions

View file

@ -0,0 +1,245 @@
// 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 = '<!-- voyage:review-lessons-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 `<claudeDir>/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(); }
});