/** * regate — does a gated head's evidence still cover HEAD (field wave CD3), * behind `gstack-pregate regate --against `. Reuse is derived only from * a tree receipt evaluated now (lib/tree-receipt.ts) with a valid evidence * bundle produced on the gated tree, the current predecessor head and the * current execution identity; a stored receipt is never read. Migration * numbers the branch adds are compared with the base branch and every open * PR; any lookup that cannot finish is `unknown`, which blocks `not-needed`. */ import { spawnSync } from 'node:child_process'; import * as path from 'node:path'; import type { ResultCodeName } from '../result-codes'; import type { ShipPolicy } from '../ship-policy'; import { buildReceipt, type GateVerdict, type ReceiptOptions, type TreeReceipt } from '../tree-receipt'; export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string }; export interface MigrationCollision { id: string; file: string; other: string; where: string } export interface MigrationReport { status: 'none' | 'collision' | 'unknown'; collisions: MigrationCollision[]; detail: string } export interface RegateReport { against: string; receipt: TreeReceipt | null; error: { code: ResultCodeName; message: string } | null; lanes: GateVerdict[]; migration: MigrationReport; verdict: 'needed' | 'not-needed'; reasons: string[]; } const PR_LIST_LIMIT = 1000; const GRAPHQL_FILES_CAP = 100; const REST_FILES_CAP = 3000; function git(cwd: string, args: string[]): string | null { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? (r.stdout ?? '').replace(/\n$/, '') : null; } const lines = (s: string | null): string[] => (s ?? '').split('\n').filter(Boolean); export const defaultGh = (cwd: string): Gh => (args) => { const r = spawnSync('gh', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }); return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? r.error?.message ?? '').trim() }; }; /** Migration numbers HEAD adds under `migrations.dir`, checked against the base tree and every other open PR. */ export function migrationReport(o: { repoRoot: string; head: string; baseRef: string; migrations: ShipPolicy['migrations']; gh: Gh; self?: string | null }): MigrationReport { if (!o.migrations) return { status: 'none', collisions: [], detail: 'no migrations key in the ship policy' }; const dir = o.migrations.dir.replace(/\/+$/, ''); const re = new RegExp(o.migrations.id); const idOf = (file: string): string | null => { if (!file.startsWith(dir + '/')) return null; const m = re.exec(path.posix.basename(file)); return m?.[1] ? String(Number.isNaN(Number(m[1])) ? m[1] : Number(m[1])) : null; }; const mergeBase = git(o.repoRoot, ['merge-base', o.baseRef, o.head]); if (!mergeBase) return { status: 'unknown', collisions: [], detail: `no merge base between ${o.baseRef} and ${o.head}` }; const ours = new Map(); for (const f of lines(git(o.repoRoot, ['diff', '--name-only', '--no-renames', '--diff-filter=A', mergeBase, o.head, '--', dir]))) { const id = idOf(f); if (id) ours.set(id, f); } if (ours.size === 0) return { status: 'none', collisions: [], detail: `this branch adds no migration under ${dir}/` }; const collisions: MigrationCollision[] = []; const check = (file: string, where: string) => { const id = idOf(file); const mine = id ? ours.get(id) : undefined; if (id && mine && mine !== file && !collisions.some(c => c.where === where && c.other === file)) collisions.push({ id, file: mine, other: file, where }); }; const baseFiles = git(o.repoRoot, ['ls-tree', '-r', '--name-only', o.baseRef, '--', dir + '/']); if (baseFiles === null) return { status: 'unknown', collisions, detail: `cannot list ${dir}/ on ${o.baseRef}` }; for (const f of lines(baseFiles)) check(f, o.baseRef); const listed = o.gh(['pr', 'list', '--state', 'open', '--limit', String(PR_LIST_LIMIT), '--json', 'number,headRefOid,files']); if (!listed.ok) return { status: 'unknown', collisions, detail: `gh pr list failed${listed.err ? `: ${listed.err.split('\n')[0]}` : ''}` }; let prs: Array<{ number: number; headRefOid?: string; files?: Array<{ path: string }> | null }>; try { prs = JSON.parse(listed.out); if (!Array.isArray(prs)) throw new Error('not a list'); } catch (e: any) { return { status: 'unknown', collisions, detail: `gh pr list output unreadable: ${e.message}` }; } if (prs.length >= PR_LIST_LIMIT) return { status: 'unknown', collisions, detail: `${prs.length} open PRs listed, the list may be truncated at ${PR_LIST_LIMIT}` }; const headSha = git(o.repoRoot, ['rev-parse', o.head]); let checked = 0; for (const pr of prs) { if (pr.headRefOid === headSha || (o.self && String(pr.number) === String(o.self))) continue; let files = (pr.files ?? []).map(f => f.path); if (files.length >= GRAPHQL_FILES_CAP) { const all = o.gh(['api', '--paginate', `repos/{owner}/{repo}/pulls/${pr.number}/files?per_page=100`, '--jq', '.[].filename']); if (!all.ok) return { status: 'unknown', collisions, detail: `PR #${pr.number} lists ${files.length} files (truncated) and its full file list could not be fetched` }; files = lines(all.out); if (files.length >= REST_FILES_CAP) return { status: 'unknown', collisions, detail: `PR #${pr.number} changes ${files.length}+ files, past what GitHub lists` }; } for (const f of files) check(f, `PR #${pr.number}`); checked += 1; } return collisions.length ? { status: 'collision', collisions, detail: `${collisions.length} collision(s)` } : { status: 'none', collisions, detail: `${[...ours.keys()].join(',')} checked against ${o.baseRef} and ${checked} open PR(s)` }; } /** The regate verdict: `not-needed` only when every required lane's evidence is reusable as is and no migration lookup is open. */ export function evaluateRegate(o: ReceiptOptions & { baseRef: string; require: string[]; self?: string | null; gh: Gh }): RegateReport { const built = buildReceipt(o); const receipt = 'error' in built ? null : built; const error = 'error' in built ? { code: built.code, message: built.error } : null; const migration = migrationReport({ repoRoot: o.repoRoot, head: o.current ?? 'HEAD', baseRef: o.baseRef, migrations: o.policy.migrations, gh: o.gh, self: o.self }); const reasons: string[] = []; let lanes: GateVerdict[] = []; if (error) reasons.push(error.message); if (receipt) { if (receipt.tree === 'changed') reasons.push(`not stamp-only: ${receipt.residual.join(', ')}`); const p = receipt.predecessor; if (p?.moved) reasons.push(`PREDECESSOR MOVED ${p.recorded_head!.slice(0, 12)} -> ${p.current_head!.slice(0, 12)}`); else if (p?.recorded_head && !p.current_head) reasons.push(`predecessor ${p.pr ? `#${p.pr}` : 'head'} current head unknown`); if (!receipt.identity.gated) reasons.push(`no gate evidence for ${receipt.gated_head.slice(0, 12)} (${receipt.identity.source})`); else { const global = reasons.join('; '); lanes = receipt.gates.map(g => g.verdict === 'reusable' && global ? { ...g, verdict: 'not-reusable' as const, reason: global } : g); for (const label of o.require) if (!lanes.some(g => g.label === label)) lanes.push({ label, verdict: 'not-reusable', reason: 'required lane has no record in the bundle' }); if (lanes.length === 0) reasons.push('the bundle lists no lanes'); for (const g of lanes) if (g.verdict !== 'reusable' && g.reason !== global) reasons.push(`${g.label} ${g.verdict === 'rerun' ? 'reruns' : 'not reusable'} (${g.reason})`); } } if (migration.status === 'collision') reasons.push(`migration collision: ${migration.collisions.map(c => c.id).join(', ')}`); if (migration.status === 'unknown') reasons.push(`migration lookup unknown (${migration.detail})`); return { against: o.gated, receipt, error, lanes, migration, verdict: reasons.length ? 'needed' : 'not-needed', reasons }; } export function renderRegate(r: RegateReport): string { const out: string[] = []; const rc = r.receipt; if (rc) { out.push(`against: ${rc.gated_head} (tree ${rc.gated_tree.slice(0, 12)})`, `head: ${rc.current_head} (tree ${rc.current_tree.slice(0, 12)})`, `version: ${rc.old_version ?? '-'} -> ${rc.new_version ?? '-'}`, `policy: ${rc.policy}`, `evidence: ${rc.identity.source}`); for (const f of rc.files) out.push(f.verdict === 'stamp' ? ` stamp ${f.path} (${f.rule})` : ` changed ${f.path}${f.line ? `:${f.line}` : ''} (${f.rule}: ${f.residual})`); out.push(`STAMP_ONLY: ${rc.tree === 'changed' ? 'no' : 'yes'} files=${rc.files.length}${rc.residual.length ? ` changed=${rc.residual.length}` : ''}`); } for (const g of r.lanes) out.push(`LANE: ${g.label} ${g.verdict} (${g.reason})`); const m = r.migration; out.push(m.status === 'collision' ? m.collisions.map(c => `MIGRATION_COLLISION: ${c.id} ${c.file} vs ${c.where} ${c.other}`).join('\n') : `MIGRATION_COLLISION: ${m.status} (${m.detail})`); out.push(r.verdict === 'not-needed' ? 'REGATE: not-needed' : `REGATE: needed (${r.reasons.join('; ')})`); return out.join('\n') + '\n'; }