fix(review): applying a lesson skips a symlinked CLAUDE.md and an ignored lessons file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 17:27:33 +01:00
commit c7fdbf0d8b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 787 additions and 24 deletions

View file

@ -34,13 +34,14 @@
// node lib/review/recurrence.mjs propose --repo <dir>
// → {"proposals":[…], "reviews_read": N, "skipped": [...]} on stdout, exit 0; exit 2 = could not run
// node lib/review/recurrence.mjs apply --repo <dir> --rule-key <KEY> [--line "<edited line>"]
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run
// → {"written":true|false, "reason":…, "pointer":…} on stdout (the pointer result,
// see applyLesson); exit 0 written, 1 refused, 2 could not run
import {
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
} from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { execFileSync } from 'node:child_process';
import { join, relative, isAbsolute, sep } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { validateReviewContent } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs';
@ -276,14 +277,69 @@ export function proposeLessons(repo) {
// ---- writing an approved lesson ---------------------------------------------
function atomicWriteText(path, text) {
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the
// real target, so the link survives and the target gets the change.
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path);
mkdirSync(dirname(path), { recursive: true });
/** lstat that answers null for a path that does not exist. */
function lstatOrNull(path) {
try { return lstatSync(path); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/**
* Write `text` to `<repo>/<rel>` atomically, never outside the repo (R3).
* Each directory of `rel` is checked one level at a time, parent first: an
* existing one must resolve (realpath) to the repo's real path or inside it; a
* missing one is created alone (non-recursive) once its parent passed, so no
* directory is ever made through a link that leaves the repo. A file that is
* itself a symlink is refused, never written through.
* @param {string} repo
* @param {string} rel path relative to the repo
* @param {string} text
* @returns {{ written: true } | { written: false, reason: string }}
*/
function atomicWriteText(repo, rel, text) {
const repoReal = realpathSync(repo);
const inside = (real) => {
const r = relative(repoReal, real);
return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
};
const parts = rel.split(/[\\/]+/).filter(Boolean);
let dir = repo;
for (let i = 0; i < parts.length - 1; i += 1) {
dir = join(dir, parts[i]);
const shown = parts.slice(0, i + 1).join('/');
if (lstatOrNull(dir)) {
const real = realpathSync(dir);
if (!inside(real)) {
return { written: false, reason: `directory-outside-repo: ${shown} points outside the repo (${real}); nothing written` };
}
} else {
mkdirSync(dir);
}
}
const path = join(repo, ...parts);
const st = lstatOrNull(path);
if (st && st.isSymbolicLink()) {
return { written: false, reason: `symlink-refused: ${parts.join('/')} is a symlink; it is not written through` };
}
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, text);
renameSync(tmp, path);
return { written: true };
}
/**
* Is the lessons file ignored by git? `git check-ignore -q` exit 0 = ignored,
* 1 = not ignored, 128 = not a git repository (treated as not ignored); a spawn
* error or any other exit is a failed check.
* @returns {{ state: 'ignored' | 'not-ignored' | 'no-git-repo' | 'failed', note?: string }}
*/
function lessonsIgnored(repo, rel) {
const r = spawnSync('git', ['-C', repo, 'check-ignore', '-q', '--', rel], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'pipe'] });
if (r.error) return { state: 'failed', note: `git check-ignore could not start: ${r.error.message}` };
if (r.status === 0) return { state: 'ignored' };
if (r.status === 1) return { state: 'not-ignored' };
if (r.status === 128) return { state: 'no-git-repo' };
const err = String(r.stderr ?? '').trim().split('\n')[0] ?? '';
const how = r.status === null ? `signal ${r.signal}` : `exit ${r.status}`;
return { state: 'failed', note: `git check-ignore ${how}${err ? `: ${err}` : ''}` };
}
export function withPointer(claudeText) {
@ -298,7 +354,12 @@ export function withPointer(claudeText) {
* @param {string} repo
* @param {string} ruleKey
* @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts]
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string }}
* The result's `pointer` (when written): 'written' | 'present' | 'no-claude-md' |
* 'symlink-skipped' (CLAUDE.md is a symlink, DJ4) | 'ignored-skipped' (git
* ignores the lessons file, PTR) | 'check-failed' (git check-ignore did not
* answer; `pointer_note` says how). Nothing is written through a symlinked
* lessons file or a lessons directory outside the repo (R3): written false.
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string, pointer_note?: string }}
*/
export function applyLesson(repo, ruleKey, opts = {}) {
const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES;
@ -333,16 +394,36 @@ export function applyLesson(repo, ruleKey, opts = {}) {
return { written: false, reason: 'over-budget', detail: 'the managed block is full — the lessons belong in a skill now (operator decision)' };
}
const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end);
atomicWriteText(path, next);
const rel = `${LESSONS_DIR}/${LESSONS_FILE_NAME}`.split(/[\\/]+/).join('/'); // git and the report use '/'
const wrote = atomicWriteText(repo, rel, next);
if (!wrote.written) return { written: false, reason: wrote.reason };
// The CLAUDE.md pointer. A symlinked CLAUDE.md is skipped, never written
// through: committing the link's target would change the R6 exception (DJ4).
// A lessons file git ignores gets no pointer: it would point at a file the
// commit does not carry (PTR). A failed ignore check writes no pointer.
let pointer = 'no-claude-md';
let pointerNote;
const claudePath = join(repo, 'CLAUDE.md');
if (existsSync(claudePath)) {
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
if (updated === null) pointer = 'present';
else { atomicWriteText(claudePath, updated); pointer = 'written'; }
const claudeStat = lstatOrNull(claudePath);
if (claudeStat && claudeStat.isSymbolicLink()) {
pointer = 'symlink-skipped';
} else if (claudeStat) {
const ignored = lessonsIgnored(repo, rel);
if (ignored.state === 'ignored') pointer = 'ignored-skipped';
else if (ignored.state === 'failed') { pointer = 'check-failed'; pointerNote = ignored.note; }
else {
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
if (updated === null) pointer = 'present';
else {
// Refused only when CLAUDE.md turned into a symlink since the lstat above.
pointer = atomicWriteText(repo, 'CLAUDE.md', updated).written ? 'written' : 'symlink-skipped';
}
}
}
return { written: true, reason: 'written', path: relative(repo, path), pointer };
const result = { written: true, reason: 'written', path: relative(repo, path), pointer };
if (pointerNote !== undefined) result.pointer_note = pointerNote;
return result;
}
// ---- CLI --------------------------------------------------------------------