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:
parent
33ef2c1b3a
commit
c7fdbf0d8b
6 changed files with 787 additions and 24 deletions
|
|
@ -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 --------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue