// lib/review/instruction-scope.mjs // The target repo's instruction files for /trekreview (review_version 1.2, fix // round 4.5b2 part B): the code-correctness reviewer checks the diff against // exactly this set (rule keys CLAUDE_MD_DEVIATION and CLAUDE_MD_STALE, MINOR), // and lib/review/review-finalize.mjs sets aside under Declined to Judge // (`instruction-out-of-set`) a finding under those keys that cites a file // outside it. // // `instructionSet(repo, sha, changedPaths)` reads the tree of the reviewed // commit with `git ls-tree -r -z --full-tree`, so only a file tracked at that // commit can enter the set (never an untracked file, never one committed // later, never a file outside the repo such as a global ~/.claude/CLAUDE.md): // - the root `CLAUDE.md` and `AGENTS.md`; // - 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); // - 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 `/.claude/rules/` for each such ancestor directory // `` 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 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}`. // // `isInstructionFile(path)` is the deep-review classifier of /trekreview // Phase 4: the base name is `CLAUDE.md` or `AGENTS.md` at any depth, or the // path is a `.md` file under a `.claude/rules/` directory. `docs/**` is // included, so a changed instruction file there gets deep review although the // triage table makes the rest of `docs/**` summary-only. // // `changedPaths(repo, start, end)` is the changed-path list the filter in // lib/review/review-finalize.mjs builds the set from: the paths of // /trekreview Phase 5's `git -c core.quotePath=false diff --name-only`, here // NUL-separated and with flags no repo config changes (`--no-relative`, // `--no-ext-diff`, `--ignore-submodules=none`, `--no-renames`). A deleted path // is listed (its directory's CLAUDE.md is in the set the reviewer was given), // and `--no-renames` lists both sides of a rename, so the filter's set always // holds the prompt's set. // // CLI: // node lib/review/instruction-scope.mjs --set --repo --sha --changed // holds the changed paths, one per line (`git diff --name-only`). // Prints {"ok": true, "files": [...]}; exit 0. A git failure or bad // arguments print {"ok": false, "reason"}; exit 2. // node lib/review/instruction-scope.mjs --triage // Prints {"files": [...]}, the given paths that are instruction files, in // the order given; exit 0. // // Every git call is an argv array with no shell. import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { posix } from 'node:path'; import { FULL_SHA_RE } from './reviewed-commit.mjs'; import { isMain } from '../util/is-main.mjs'; const MAX_BUFFER = 64 * 1024 * 1024; // The symlink mode of lib/review/reviewed-commit.mjs:48 (copied, not exported there). 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_DIR = '.claude/rules/'; // The `.md` test for the part after `/.claude/rules/`; isInstructionFile // below builds its own test from the same source, so set and classifier agree. const RULES_REST = '(?:[^/]+\\/)*[^/]+\\.md$'; const RULES_REST_RE = new RegExp(`^${RULES_REST}`); const RULES_ANY_DEPTH_RE = new RegExp(`(?:^|\\/)\\.claude\\/rules\\/${RULES_REST}`); // The argv git helper of lib/review/reviewed-commit.mjs:64-66 (copied, not exported there). function git(repo, args) { return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: MAX_BUFFER }); } function gitFailed(what, r) { const note = r.error?.code === 'ENOBUFS' ? 'output past the 64 MiB buffer' : (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]); return { ok: false, reason: `instruction set not measured: git ${what} failed (${note || `exit ${r.status}`})` }; } /** A repo-relative posix path: no leading `/`, no `.`, `..` or empty segment, no backslash. */ function plainPath(p) { return typeof p === 'string' && p !== '' && !p.includes('\\') && !p.includes('\0') && p.split('/').every((s) => s !== '' && s !== '.' && s !== '..'); } /** Every ancestor directory of a plain path, the root ('') first. */ function ancestors(p) { const parts = p.split('/').slice(0, -1); const out = ['']; for (let i = 1; i <= parts.length; i++) out.push(parts.slice(0, i).join('/')); return out; } /** The tree of `sha`: Map, or {ok:false}. */ function treeAt(repo, sha) { const r = git(repo, ['ls-tree', '-r', '-z', '--full-tree', sha]); if (r.error || r.status !== 0) return gitFailed(`ls-tree ${sha.slice(0, 12)}`, r); const entries = new Map(); for (const entry of r.stdout.split('\0')) { if (entry === '') continue; const tab = entry.indexOf('\t'); const m = tab === -1 ? null : TREE_ENTRY_RE.exec(entry.slice(0, tab)); if (!m) return { ok: false, reason: `instruction set not measured: cannot read ls-tree entry ${JSON.stringify(entry.slice(0, 120))}` }; entries.set(entry.slice(tab + 1), { mode: m[1], type: m[2], hash: m[3] }); } return { ok: true, entries }; } /** * 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, 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, paths: [] }; const resolved = posix.normalize(posix.join(posix.dirname(path), target)); if (!plainPath(resolved)) return { ok: true, paths: [] }; const t = entries.get(resolved); return { ok: true, paths: t && t.type === 'blob' && REGULAR_MODES.has(t.mode) ? [path, resolved] : [] }; } /** * Is `p` a `.md` file under `/.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; } /** * The paths changed over `start..end`, deleted ones included. * @param {string} repo * @param {string} start - a full commit id * @param {string} end - a full commit id * @returns {{ok: true, paths: string[]} | {ok: false, reason: string}} */ export function changedPaths(repo, start, end) { if (!repo) return { ok: false, reason: 'changed paths not measured: no repo' }; if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(end ?? ''))) { return { ok: false, reason: 'changed paths not measured: start and end must be full commit ids' }; } const r = git(repo, ['-c', 'core.quotePath=false', 'diff', '--name-only', '-z', '--no-relative', '--no-ext-diff', '--ignore-submodules=none', '--no-renames', start, end]); if (r.error || r.status !== 0) return gitFailed('diff --name-only', r); return { ok: true, paths: r.stdout.split('\0').filter((p) => p !== '') }; } /** * The repo's tracked instruction files at `sha` for the changed paths. * @param {string} repo * @param {string} sha - a full commit id * @param {string[]} changedPaths - repo-relative paths; any other is ignored * @returns {{ok: true, files: string[]} | {ok: false, reason: string}} */ export function instructionSet(repo, sha, changedPaths = []) { if (!repo) return { ok: false, reason: 'instruction set not measured: no repo' }; if (!FULL_SHA_RE.test(String(sha ?? ''))) return { ok: false, reason: 'instruction set not measured: the sha must be a full commit id' }; const commit = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); if (commit.error || commit.status !== 0) return gitFailed(`rev-parse ${sha.slice(0, 12)}`, commit); const tree = treeAt(repo, sha); if (!tree.ok) return tree; const dirs = new Set(['']); for (const p of Array.isArray(changedPaths) ? changedPaths : []) { if (plainPath(p)) for (const d of ancestors(p)) dirs.add(d); } 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 (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; for (const f of r.paths) files.add(f); } return { ok: true, files: [...files].sort() }; } /** * Does a changed file get deep review as an instruction file? The base name * is `CLAUDE.md` or `AGENTS.md` at any depth, or it is a `.md` file under a * `.claude/rules/` directory; `docs/**` included. * @param {string} path * @returns {boolean} */ export function isInstructionFile(path) { if (typeof path !== 'string' || path === '') return false; const p = path.replace(/^\.\//, ''); if (NAMES.includes(posix.basename(p))) return true; return RULES_ANY_DEPTH_RE.test(p); } function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } /** The CLI's work: {exit, out}. */ export function runCli(args) { if (args[0] === '--triage') return { exit: 0, out: { files: args.slice(1).filter(isInstructionFile) } }; if (args[0] !== '--set') return { exit: 2, out: { ok: false, reason: 'usage: --set --repo --sha --changed | --triage ' } }; const repo = opt(args, '--repo'); const sha = opt(args, '--sha'); const changed = opt(args, '--changed'); if (!repo || !sha || !changed) return { exit: 2, out: { ok: false, reason: '--set needs --repo, --sha and --changed' } }; let list; try { list = readFileSync(changed, 'utf8').split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l !== ''); } catch (e) { return { exit: 2, out: { ok: false, reason: `cannot read --changed ${changed}: ${e.message}` } }; } const r = instructionSet(repo, sha, list); return { exit: r.ok ? 0 : 2, out: r }; } if (isMain(import.meta.url)) { let result; try { result = runCli(process.argv.slice(2)); } catch (e) { result = { exit: 2, out: { ok: false, reason: `instruction-scope crashed: ${e.message}` } }; } process.stdout.write(`${JSON.stringify(result.out)}\n`); process.exit(result.exit); }