fix(review): the instruction set holds nested rules files and a link's own name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 10:16:34 +01:00
commit d4daa9d787
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 287 additions and 19 deletions

View file

@ -14,11 +14,17 @@
// - the same names in each ancestor directory of each changed path (a
// changed path that is absolute, holds `..`, `.`, an empty segment or a
// backslash names no directory of the repo and adds nothing);
// - `.claude/rules/*.md`.
// - every `.md` file under the root's `.claude/rules/` at any depth
// (`.claude/rules/sub/x.md` included), the test `isInstructionFile` uses;
// - the same under `<dir>/.claude/rules/` for each such ancestor directory
// `<dir>` of a changed path (`pkg/.claude/rules/x.md` only when a changed
// path lies under `pkg/`).
// An entry with mode 120000 (a symlink) has its target read with
// `git cat-file`. A target resolved against the link's directory that stays
// inside the repo and is a tracked regular file replaces the link; any other
// target (absolute, out of the repo, missing, another link) drops the entry.
// inside the repo and is a tracked regular file enters the set next to the
// link, which keeps its own name (CLAUDE.md -> AGENTS.md gives both paths);
// any other target (absolute, out of the repo, missing, another link) drops
// the entry, link name included.
// Every path is repo-relative posix. A git failure, or a sha that is not a
// full commit id, gives `{ok:false, reason}`.
//
@ -60,7 +66,9 @@ const SYMLINK_MODE = '120000';
const REGULAR_MODES = new Set(['100644', '100755']);
const TREE_ENTRY_RE = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/;
const NAMES = ['CLAUDE.md', 'AGENTS.md'];
const RULES_RE = /^\.claude\/rules\/[^/]+\.md$/;
const RULES_DIR = '.claude/rules/';
// The `.md` test of isInstructionFile below, for the part after `<d>/.claude/rules/`.
const RULES_REST_RE = /^(?:[^/]+\/)*[^/]+\.md$/;
// The argv git helper of lib/review/reviewed-commit.mjs:64-66 (copied, not exported there).
function git(repo, args) {
@ -103,22 +111,36 @@ function treeAt(repo, sha) {
}
/**
* The path an instruction entry stands for: itself when it is a regular file;
* for a symlink, its tracked regular target inside the repo; else null.
* The paths an instruction entry stands for: `[path]` when it is a regular
* file; for a symlink whose target is a tracked regular file inside the repo,
* `[link, target]`; else `[]`.
*/
function resolveEntry(repo, entries, path) {
const e = entries.get(path);
if (!e || e.type !== 'blob') return { ok: true, path: null };
if (REGULAR_MODES.has(e.mode)) return { ok: true, path };
if (e.mode !== SYMLINK_MODE) return { ok: true, path: null };
if (!e || e.type !== 'blob') return { ok: true, paths: [] };
if (REGULAR_MODES.has(e.mode)) return { ok: true, paths: [path] };
if (e.mode !== SYMLINK_MODE) return { ok: true, paths: [] };
const r = git(repo, ['cat-file', 'blob', e.hash]);
if (r.error || r.status !== 0) return gitFailed(`cat-file ${path}`, r);
const target = r.stdout;
if (target === '' || target.includes('\0') || target.includes('\\') || posix.isAbsolute(target)) return { ok: true, path: null };
if (target === '' || target.includes('\0') || target.includes('\\') || posix.isAbsolute(target)) return { ok: true, paths: [] };
const resolved = posix.normalize(posix.join(posix.dirname(path), target));
if (!plainPath(resolved)) return { ok: true, path: null };
if (!plainPath(resolved)) return { ok: true, paths: [] };
const t = entries.get(resolved);
return { ok: true, path: t && t.type === 'blob' && REGULAR_MODES.has(t.mode) ? resolved : null };
return { ok: true, paths: t && t.type === 'blob' && REGULAR_MODES.has(t.mode) ? [path, resolved] : [] };
}
/**
* Is `p` a `.md` file under `<d>/.claude/rules/` at any depth, for some `d`
* in `dirs` (the root '' or a directory on a changed path)?
*/
function rulesFileOnPath(p, dirs) {
for (let i = p.indexOf(RULES_DIR); i !== -1; i = p.indexOf(RULES_DIR, i + 1)) {
if (i > 0 && p[i - 1] !== '/') continue;
const d = i === 0 ? '' : p.slice(0, i - 1);
if (dirs.has(d) && RULES_REST_RE.test(p.slice(i + RULES_DIR.length))) return true;
}
return false;
}
/**
@ -159,12 +181,12 @@ export function instructionSet(repo, sha, changedPaths = []) {
}
const candidates = [];
for (const d of dirs) for (const n of NAMES) candidates.push(d === '' ? n : `${d}/${n}`);
for (const p of tree.entries.keys()) if (RULES_RE.test(p)) candidates.push(p);
for (const p of tree.entries.keys()) if (rulesFileOnPath(p, dirs)) candidates.push(p);
const files = new Set();
for (const c of candidates) {
const r = resolveEntry(repo, tree.entries, c);
if (!r.ok) return r;
if (r.path !== null) files.add(r.path);
for (const f of r.paths) files.add(f);
}
return { ok: true, files: [...files].sort() };
}