1// Approval rules by path: .forge/approvals on a repo's default branch says2// how much approval a change to main needs, by the files it changes. The3// pre-receive gate (with forge.requireapproval), forge land and the review4// pages all ask this module, so they never disagree.5//6// # paths rule7// .forge/** bin/forge 28// lib/identity.ts lib/login.ts 1 from fox9// lib/** 1 + agent10// *.md guide/** wiki/** none11// @mechanical none12// * 113//14// Each changed file takes the first line it matches. A rule is "none", or a15// number of approvals, optionally "from" a comma-separated list of people16// (then that many of them), optionally "+ agent": a verified agent review17// of the tree with no blocking findings (forge critique). A change needs18// every rule its files took. A file no line matches needs 1, as without a19// file. @mechanical matches files Jev judged mechanical (the review page20// folds them); a file with no stored answer isn't. A rename counts both21// its paths, so moving a file out of a guarded directory needs that rule.22//23// Paths: a pattern without a slash matches a file's name in any directory;24// one with a slash matches from the top. * is any run of characters but /,25// ** any run at all, ? one character but /, and a trailing / is everything26// under that directory.27//28// The rules come from the default branch as it is before the change (the29// tip a push would move), never from the change itself, so a review can't30// loosen its own rules: a change to .forge/approvals lands under the old31// ones.32//33// node lib/rules.ts check --git-dir <dir> --base <commit|-> --tip <commit> [--rules-at <commit|->] [--json]34//35// prints what the change needs and has, and exits 1 if it isn't met.36import fs from 'node:fs';37import { fileURLToPath } from 'node:url';38import { tryRun } from './git.ts';39import { JevStore } from './jev.ts';40import { mechanicalPair } from './jev-reviews.ts';41import { readReviewLogs } from './reviews.ts';42import { agentReviews, type AgentReview } from './critique.ts';4344export const RULES_FILE = '.forge/approvals';4546export interface Rule {47 // 1-based line in the file; 0 for the default.48 line: number;49 text: string;50 patterns: string[];51 mechanical: boolean;52 // Approvals needed (0 with none).53 need: number;54 from: string[] | null;55 agent: boolean;56 res: RegExp[];57}5859export const DEFAULT_RULE: Rule = { line: 0, text: '1 (the default)', patterns: [], mechanical: false, need: 1, from: null, agent: false, res: [] };6061// A glob as a regex over the whole path.62export function globRe(p: string): RegExp {63 let g = p.endsWith('/') ? `${p}**` : p;64 const anchored = g.slice(0, -1).includes('/') || g.startsWith('/');65 g = g.replace(/^\//, '');66 let re = '';67 for (let i = 0; i < g.length; i++) {68 const c = g[i];69 if (c === '*' && g[i + 1] === '*') {70 i++;71 if (g[i + 1] === '/') {72 i++;73 re += '(?:.*/)?';74 } else re += '.*';75 } else if (c === '*') re += '[^/]*';76 else if (c === '?') re += '[^/]';77 else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');78 }79 return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}$`);80}8182const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;8384export function parseRules(text: string): { rules: Rule[]; errors: string[] } {85 const rules: Rule[] = [];86 const errors: string[] = [];87 text.split('\n').forEach((raw, i) => {88 const line = raw.replace(/#.*/, '').trim();89 if (!line) return;90 const words = line.split(/\s+/);91 const at = words.findIndex((w) => w === 'none' || /^\d+$/.test(w));92 const bad = (why: string): void => void errors.push(`${RULES_FILE}:${i + 1}: ${why}`);93 if (at <= 0) return bad('expected paths, then a rule: none, or a number of approvals');94 const patterns = words.slice(0, at);95 const rest = words.slice(at);96 let need = 0;97 let from: string[] | null = null;98 let agent = false;99 if (rest[0] === 'none') {100 if (rest.length > 1) return bad('nothing may follow none');101 } else {102 need = Number(rest[0]);103 let j = 1;104 if (rest[j] === 'from') {105 from = (rest[j + 1] ?? '').split(',').filter(Boolean);106 if (!from.length || !from.every((n) => NAME.test(n))) return bad('from takes a comma-separated list of names');107 if (need > from.length) return bad(`${need} approvals from ${from.length} ${from.length === 1 ? 'person' : 'people'} can never be met`);108 j += 2;109 }110 if (rest[j] === '+' && rest[j + 1] === 'agent') {111 agent = true;112 j += 2;113 }114 if (j < rest.length) return bad(`unexpected "${rest.slice(j).join(' ')}"; a rule is none, or N [from a,b] [+ agent]`);115 if (need === 0 && !agent) return bad('0 approvals: write none');116 }117 const mechanical = patterns.includes('@mechanical');118 const globs = patterns.filter((p) => p !== '@mechanical');119 if (patterns.some((p) => p.startsWith('@') && p !== '@mechanical')) return bad('the only @ pattern is @mechanical');120 rules.push({ line: i + 1, text: rest.join(' '), patterns, mechanical, need, from, agent, res: globs.map(globRe) });121 });122 return { rules, errors };123}124125export function ruleFor(rules: Rule[], file: string, mechanical: boolean): Rule {126 return rules.find((r) => (r.mechanical && mechanical) || r.res.some((re) => re.test(file))) ?? DEFAULT_RULE;127}128129export interface Need {130 rule: Rule;131 paths: string[];132 // Approvers who count for it.133 have: string[];134 agentOk: boolean;135 ok: boolean;136}137138export interface Verdict {139 ok: boolean;140 // Rules a change needs, in file order (none left out).141 needs: Need[];142 // Each changed path and the line of the rule it took (0: the default).143 files: [string, number][];144 errors: string[];145}146147export interface Changed {148 path: string;149 mechanical: boolean;150}151152export function judge(rules: Rule[], files: Changed[], approvers: string[], agentOk: boolean, errors: string[] = []): Verdict {153 const byLine = new Map<number, Need>();154 const out: [string, number][] = [];155 for (const f of files) {156 const r = ruleFor(rules, f.path, f.mechanical);157 out.push([f.path, r.line]);158 if (!r.need && !r.agent) continue;159 let n = byLine.get(r.line);160 if (!n) {161 const have = r.from ? approvers.filter((a) => r.from!.includes(a)) : approvers;162 n = { rule: r, paths: [], have, agentOk, ok: have.length >= r.need && (!r.agent || agentOk) };163 byLine.set(r.line, n);164 }165 n.paths.push(f.path);166 }167 const needs = [...byLine.values()];168 return { ok: needs.every((n) => n.ok) && !errors.length, needs, files: out, errors };169}170171// What a need says, in a sentence's middle: "2 approvals", "1 from fox",172// "1 approval and an agent review".173export function needText(r: Rule): string {174 const parts: string[] = [];175 if (r.need) parts.push(r.from ? `${r.need} from ${r.from.join(r.need === r.from.length && r.need > 1 ? ' and ' : ' or ')}` : `${r.need} ${r.need === 1 ? 'approval' : 'approvals'}`);176 if (r.agent) parts.push('an agent review');177 return parts.join(' and ');178}179180// ---------------------------------------------------------------- git181182export function rulesAt(gitDir: string, commit: string | null): { rules: Rule[]; errors: string[]; found: boolean } {183 const text = commit ? tryRun(gitDir, ['cat-file', 'blob', `${commit}:${RULES_FILE}`]) : null;184 if (text == null) return { rules: [], errors: [], found: false };185 return { ...parseRules(text), found: true };186}187188// The files a change from base (null: none) to tip touches, both paths of189// a rename, and whether Jev judged each change mechanical.190export function changedFiles(gitDir: string, base: string | null, tip: string): Changed[] {191 const raw = base192 ? tryRun(gitDir, ['diff', '--raw', '-z', '-M', '--abbrev=40', '--no-color', base, tip])193 : tryRun(gitDir, ['diff', '--raw', '-z', '--abbrev=40', '--no-color', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', tip]);194 if (!raw) return [];195 const store = new JevStore(gitDir);196 const parts = raw.split('\0');197 const out: Changed[] = [];198 const seen = new Set<string>();199 for (let i = 0; i < parts.length; i++) {200 const head = parts[i];201 if (!head.startsWith(':')) continue;202 const [, , a, b, status] = head.slice(1).split(' ');203 const paths = status.startsWith('R') || status.startsWith('C') ? [parts[++i], parts[++i]] : [parts[++i]];204 const mechanical = mechanicalPair(store, a, b);205 for (const f of paths) {206 if (f == null || seen.has(f)) continue;207 seen.add(f);208 out.push({ path: f, mechanical });209 }210 }211 return out;212}213214// Who approved each tree on a review they didn't open (bin/forge's approvers).215export function approverMap(gitDir: string): Map<string, string[]> {216 const by = new Map<string, Set<string>>();217 for (const r of readReviewLogs(gitDir, false)) {218 for (const a of r.approvals) {219 if (a.user === r.created?.user) continue;220 if (!by.has(a.tree)) by.set(a.tree, new Set());221 by.get(a.tree)!.add(a.user);222 }223 }224 return new Map([...by].map(([t, s]) => [t, [...s].sort()]));225}226227export const approversOf = (gitDir: string, tree: string): string[] => approverMap(gitDir).get(tree) ?? [];228229export interface CheckOptions {230 gitDir: string;231 base: string | null;232 tip: string;233 // Where the rules are read: the default branch before the change.234 rulesAt: string | null;235 allowedSigners?: string | null;236 // The review notes to read: a push can bring new ones (pre-receive).237 reviewNotes?: string;238 // Given by the page build, which has them: who approved a tree, and its239 // agent reviews.240 approvers?: (tree: string) => string[];241 agent?: (tree: string) => AgentReview[];242}243244// Does a tree have what "+ agent" asks for: a verified agent review with no245// blocking finding open?246export const agentPasses = (list: AgentReview[]): boolean => list.some((r) => r.ok && r.open === 0);247248export function check(o: CheckOptions): Verdict & { found: boolean } {249 const { rules, errors, found } = rulesAt(o.gitDir, o.rulesAt);250 const tree = tryRun(o.gitDir, ['rev-parse', `${o.tip}^{tree}`])?.trim() ?? '';251 const files = changedFiles(o.gitDir, o.base, o.tip);252 const needsAgent = files.some((f) => ruleFor(rules, f.path, f.mechanical).agent);253 const agentOk = needsAgent && !!tree && agentPasses(o.agent ? o.agent(tree) : agentReviews(o.gitDir, tree, o.allowedSigners ?? null, o.reviewNotes));254 const approvers = o.approvers ? o.approvers(tree) : approversOf(o.gitDir, tree);255 return { ...judge(rules, files, approvers, agentOk, errors), found };256}257258// Lines for a terminal: what each rule needs and has.259export function report(v: Verdict & { found: boolean }): string[] {260 const lines: string[] = [...v.errors];261 if (!v.needs.length) lines.push(v.files.length ? 'no approval needed: every changed file is under a "none" rule' : 'no approval needed: no files changed');262 for (const n of v.needs) {263 const what = n.rule.line ? `${RULES_FILE}:${n.rule.line}` : 'the default';264 const shown = n.paths.slice(0, 3).join(', ') + (n.paths.length > 3 ? `, ${n.paths.length - 3} more` : '');265 const has = [n.rule.need ? (n.have.length ? `approved by ${n.have.join(', ')}` : 'no approvals') : '', n.rule.agent ? (n.agentOk ? 'an agent review' : 'no passing agent review') : ''].filter(Boolean).join(', ');266 lines.push(`${n.ok ? 'ok ' : 'NEED'} ${needText(n.rule)} (${what}) for ${shown}: ${has}`);267 }268 return lines;269}270271// ---------------------------------------------------------------- cli272273// Run directly (realpath: /tmp is a symlink on macOS, and import.meta.url is resolved).274if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {275 const args = process.argv.slice(2);276 const opt = (k: string): string | null => {277 const i = args.indexOf(k);278 return i >= 0 ? (args[i + 1] ?? null) : null;279 };280 const commit = (k: string): string | null => {281 const v = opt(k);282 return !v || v === '-' || /^0+$/.test(v) ? null : v;283 };284 if (args[0] === 'mechanical' && opt('--git-dir') && opt('--tip')) {285 // The changed paths Jev judged mechanical, for forge critique to skip.286 for (const f of changedFiles(opt('--git-dir')!, commit('--base'), opt('--tip')!)) if (f.mechanical) process.stdout.write(f.path + '\n');287 process.exit(0);288 }289 if (args[0] !== 'check' || !opt('--git-dir') || !opt('--tip')) {290 process.stderr.write('usage: node lib/rules.ts check --git-dir <dir> --base <commit|-> --tip <commit> [--rules-at <commit|->] [--signers <file>] [--json]\n');291 process.exit(2);292 }293 const gitDir = opt('--git-dir')!;294 const base = commit('--base');295 const signers = opt('--signers');296 const v = check({297 gitDir, base, tip: opt('--tip')!, rulesAt: args.includes('--rules-at') ? commit('--rules-at') : base,298 allowedSigners: signers && fs.existsSync(signers) ? signers : null, reviewNotes: commit('--review-notes') ?? undefined,299 });300 if (args.includes('--json')) {301 process.stdout.write(JSON.stringify({ ok: v.ok, found: v.found, errors: v.errors, needs: v.needs.map((n) => ({ line: n.rule.line, need: needText(n.rule), paths: n.paths, have: n.have, agent: n.rule.agent, agentOk: n.agentOk, ok: n.ok })) }) + '\n');302 } else process.stdout.write(report(v).join('\n') + '\n');303 process.exit(v.ok ? 0 : 1);304}