243 lines
11 KiB
JavaScript
243 lines
11 KiB
JavaScript
// 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 `<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 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 <d> --sha <s> --changed <file>
|
|
// <file> 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 <paths...>
|
|
// 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 `<d>/.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<path, {mode, type, hash}>, 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 `<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;
|
|
}
|
|
|
|
/**
|
|
* 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 <d> --sha <s> --changed <file> | --triage <paths...>' } };
|
|
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);
|
|
}
|