feat(approval-classes): gstack-approval-classes and ship-receipt citation enforcement (W2, WIP)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 847c7dae3d)
This commit is contained in:
garrytan committed 2026-10-17 01:22:58 +00:00
1 parent b36c48aec9
commit f6835fc16b
8 files changed
+662 -6

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bun
/**
* gstack-approval-classes — changes that need the owner's approval (field list 2, W2).
*
* gstack-approval-classes [check] [--base <ref>] [--head <ref>] [--diff-base <sha>] [--json]
* gstack-approval-classes init [--force]
* gstack-approval-classes validate
*
* `check` reads the classes from the committed `origin/<base>` copy of
* .gstack/approval-classes.json (never the working tree) and the migration
* rule from the base's ship policy, scans the branch's changes since the
* merge base, and prints `APPROVAL_CLASS: <class> <file>:<line>` per hit, one
* `APPROVAL_SCOPE: <class>@<digest12>` per declared class with hits, and
* `APPROVAL_CLASSES: ok | found=<n> | none` last. Built-in detectors only
* report. Exit 0 ok or none, 1 found, 3 when the base file is invalid or the
* base is unresolvable. Enforcement is gstack-ship-receipt write
* (APPROVAL_CITATION_MISSING). Logic: lib/approval-classes.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { APPROVAL_CLASSES_PATH, exampleApprovalClassesJson, loadWorkingApprovalClasses, renderApprovalScan, runApprovalScan } from '../lib/approval-classes';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { resolveBaseBranch } from '../lib/ship-policy';
const HELP = `gstack-approval-classes — changes that need the owner's approval (docs/ship-policy.md#approval-classes)
check [--base <ref>] [--head <ref>] [--diff-base <sha>] [--json]
changes since the merge base with origin/<base> (default origin/HEAD's branch), at --head or the
working tree; classes come only from the committed ${APPROVAL_CLASSES_PATH} on origin/<base>
init [--force] write an example ${APPROVAL_CLASSES_PATH}
validate schema and containment check of the working-tree file (before you commit it)
A declared class with hits needs a line in the PR body that gstack-ship-receipt write can resolve:
Approved by <owner> <YYYY-MM-DD> via <plan sha|comment or review URL|ref> [for <class>@<digest12>]
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const cmd = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'check';
const rest = cmd === argv[0] ? argv.slice(1) : argv;
const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
if (root.status !== 0) { process.stderr.write('gstack-approval-classes: not inside a git repository\n'); return EXIT.usage; }
const repoRoot = root.stdout.trim();
const file = path.join(repoRoot, APPROVAL_CLASSES_PATH);
if (cmd === 'init') {
if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(`gstack-approval-classes: ${APPROVAL_CLASSES_PATH} exists (pass --force to overwrite)\n`); return EXIT.refused; }
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, exampleApprovalClassesJson());
process.stdout.write(`APPROVAL_CLASSES_INIT: ${APPROVAL_CLASSES_PATH} (edit, commit and merge it; check reads only the base copy)\n`);
return EXIT.ok;
}
if (cmd === 'validate') {
const load = loadWorkingApprovalClasses(repoRoot);
if ('absent' in load) { process.stdout.write(`APPROVAL_CLASSES: none (no ${APPROVAL_CLASSES_PATH})\n`); return EXIT.ok; }
if ('errors' in load) { for (const e of load.errors) process.stderr.write(resultLine('gstack-approval-classes validate', 'APPROVAL_CLASSES_INVALID', `${e.message} at ${e.path || '<root>'}`) + '\n'); return EXIT.refused; }
if ('unavailable' in load || !load.config) return EXIT.fail;
process.stdout.write(`APPROVAL_CLASSES_VALID: ${APPROVAL_CLASSES_PATH} classes=${load.config.classes.length}\n`);
return EXIT.ok;
}
if (cmd !== 'check') { process.stderr.write(`gstack-approval-classes: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; }
const json = rest.includes('--json');
const base = resolveBaseBranch(repoRoot, flag(rest, '--base'));
const outcome = runApprovalScan({ repoRoot, base, head: flag(rest, '--head'), diffBase: flag(rest, '--diff-base') });
if ('unavailable' in outcome) { process.stderr.write(resultLine('gstack-approval-classes', 'APPROVAL_SOURCE_UNAVAILABLE', `${outcome.unavailable}; classes are read only from the base, so nothing was checked`) + '\n'); return EXIT.refused; }
if ('errors' in outcome) { for (const e of outcome.errors) process.stderr.write(resultLine('gstack-approval-classes', 'APPROVAL_CLASSES_INVALID', `${e.message} at ${e.path || '<root>'} (${outcome.source})`) + '\n'); return EXIT.refused; }
if ('error' in outcome) { process.stderr.write(`gstack-approval-classes: ${outcome.error}\n`); return EXIT.usage; }
const { scan, absent } = outcome;
process.stdout.write(json ? JSON.stringify({ ...scan, absent }) + '\n' : renderApprovalScan(scan, absent));
if (scan.hits.length) { process.stderr.write(resultLine('gstack-approval-classes', 'APPROVAL_CLASS_FOUND', `${scan.scopes.map(s => s.token).join(', ')} need an Approved by line`) + '\n'); return EXIT.fail; }
return EXIT.ok;
}
process.exitCode = main(process.argv.slice(2));
+29 -5
View File
@@ -6,7 +6,8 @@
* [--predecessor <sha>] [--gate <json|@file>] [--ci <json|@file>] [--spend-usd <n>]
* [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
* [--pregate <pregate.json>] [--issues <json|@file>] [--out <file>] [--body <pr-body.md>] [--json]
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
* [--out <file>] [--body <pr-body.md>] [--json]
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--json]
* gstack-ship-receipt read --body <file> [--json]
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
@@ -24,6 +25,7 @@ import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { resolveBaseBranch } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
import {
computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock,
validateReceipt, type CensusPr,
@@ -41,7 +43,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
--session-kind --artifacts-consumed <n>/<m> --run --thread --receipt <tree-receipt.json> --policy --preregistration
--history --queue-mode --pregate <pregate.json> (C8: refused unless current, green and every requires-remote obligation
carries a receipt: PREGATE_STALE / PREGATE_LANE_FAILED / PREGATE_INCOMPLETE / PREGATE_REMOTE_UNCLEARED) --issues <json|@file>
(C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line) --out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
(C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line)
--approval <"Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>]"> (repeatable; the --body file's lines count too)
--approval-run <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
version source at HEAD, session_kind=$GSTACK_SESSION_KIND (gstack-session-kind), tree/gate_reuse/gated_tree/predecessor
from --receipt when given.
--json: JSON only on stdout.
@@ -51,6 +57,9 @@ function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function flags(args: string[], name: string): string[] {
return args.flatMap((a, i) => (a === name && args[i + 1] !== undefined ? [args[i + 1]!] : []));
}
function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
if (raw === undefined) return undefined;
const text = raw.startsWith('@') ? fs.readFileSync(raw.slice(1), 'utf8') : raw;
@@ -92,19 +101,34 @@ function cmdWrite(rest: string[], json: boolean): number {
const remote = read.file.requires_remote ?? [];
pregate = { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined };
}
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const bodyFile = flag(rest, '--body');
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
if (approval.kind === 'invalid') {
for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n');
return EXIT.fail;
}
if (approval.kind === 'error') { process.stderr.write(`gstack-ship-receipt write: approval classes: ${approval.message}; nothing written\n`); return EXIT.fail; }
if (approval.kind === 'missing') {
for (const m of approval.missing) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CITATION_MISSING', `${m.class} (${m.token}; effects in ${m.files.join(', ')}): ${m.reasons.join('; ')}`) + '\n');
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
if (approval.kind === 'ok' && approval.note) process.stderr.write(approval.note + '\n');
let input: Partial<ShipReceipt>;
try {
const issuesFile = jsonArg(flag(rest, '--issues'));
input = {
schema_version: 1, pr: flag(rest, '--pr') !== undefined ? Number(flag(rest, '--pr')) : undefined, head,
version: flag(rest, '--version') ?? ('version' in atHead ? atHead.version : undefined), base: flag(rest, '--base') ?? resolveBaseBranch(repoRoot),
version: flag(rest, '--version') ?? ('version' in atHead ? atHead.version : undefined), base,
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')),
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined,
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -115,7 +139,7 @@ function cmdWrite(rest: string[], json: boolean): number {
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
const body = flag(rest, '--body');
const body = bodyFile;
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
if (missing.length) {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'ISSUE_LINKS_MISSING', `${body} lacks ${missing.map(l => JSON.stringify(l)).join(', ')}`) + '\n');
+65
View File
@@ -3557,3 +3557,68 @@ an event or a poll with a deadline. When the bound is the contract, add
`gstack-timing-ok: <reason>` in a comment on the line or the line above. Never
raise a bound to make a flake pass; /investigate's flake playbook covers the
diagnosis.
<a id="approval-classes-invalid"></a>
### `APPROVAL_CLASSES_INVALID`
**Meaning.** The `.gstack/approval-classes.json` committed on the base branch
(or, for `gstack-approval-classes validate`, the working-tree copy) is not
valid JSON, has an unknown key, a class without a name or anything to match,
or a glob that escapes the repository. `gstack-ship-receipt write` refuses
rather than ship with the classes unchecked.
**Fix.** Run `gstack-approval-classes validate`, correct the file, and merge
it. See [Approval classes](ship-policy.md#approval-classes).
<a id="approval-class-found"></a>
### `APPROVAL_CLASS_FOUND`
**Meaning.** `gstack-approval-classes check` found changes a declared class
covers. Each `APPROVAL_CLASS:` line names the class and file:line, and each
`APPROVAL_SCOPE: <class>@<digest12>` line is the token an approval binds to.
Report-only lines from built-in detectors do not set this code.
**Fix.** Ask the owner, then add
`Approved by <owner> <YYYY-MM-DD> via <ref> for <class>@<digest12>` to the PR
body. The token changes whenever an effect is added, removed or edited, so a
later change needs a new approval.
<a id="approval-citation-missing"></a>
### `APPROVAL_CITATION_MISSING`
**Meaning.** `gstack-ship-receipt write` found a declared approval class with
no citation that both resolves and binds to the current effects, and wrote no
receipt. The line lists every candidate citation and why it did not count:
the plan's autoplan gate is not APPROVED, the comment is not by a ship-policy
maintainer or not by the cited owner, the evidence names neither the scope
token nor the class with every effect path (an approval of other work, or one
given before an effect was added), or an unresolvable reference lacks a
matching `for <class>@<digest12>` attestation.
**Fix.** Cite the approval: a plan sha with `--approval-run <run dir>`, or the
URL of a maintainer's comment or review that names the token. For evidence
gstack cannot read (a thread code, a chat message), attest which scope it
covers with `for <class>@<digest12>`; the receipt records it as
`citation: unverified` for the coordinator.
<a id="approval-source-unavailable"></a>
### `APPROVAL_SOURCE_UNAVAILABLE`
**Meaning.** `origin/<base>` does not resolve, so the committed approval
classes cannot be read. `check` exits 3; `gstack-ship-receipt write`
records `approval.status: unavailable` and prints `approval=unchecked`.
**Fix.** `git fetch origin <base>` and rerun. The working-tree copy is never
used.
<a id="state-matrix-incomplete"></a>
### `STATE_MATRIX_INCOMPLETE`
**Meaning.** `gstack-state-matrix check` read the state-machines specialist's
output and found a gap: a kind × owner action pair with no row, a row with no
resulting row, a test reference that does not exist in the checkout, or an
untested cell without a CRITICAL finding. A review whose matrix is incomplete
has incomplete coverage, never a clean result.
**Fix.** Rerun the state-machines specialist on the same diff and check its
output again.
+204
View File
@@ -0,0 +1,204 @@
/**
* approval-citations — the `Approved by <owner> <YYYY-MM-DD> via <ref>`
* lines that let a change in a declared approval class ship (field list 2,
* W2; lib/approval-classes.ts finds the classes). A citation is resolved,
* never trusted as typed:
*
* - a plan sha (7-64 hex) must match the plan of an autoplan run passed with
* `--approval-run <dir>` whose gate is APPROVED (status complete, the p1
* decision answered and not reject/interrogate), with plan.md matching its
* manifest hash;
* - a GitHub comment or review URL must be written by a ship-policy
* maintainer whose login is the cited owner (a review requesting changes is
* not an approval);
* - anything else, or a ref that cannot be read here, is `unverified`.
*
* Resolution alone is not enough: the evidence (the approved plan text or
* the comment body) must bind to the class's current effects, by carrying the
* scope token `<class>@<digest12>` or by naming the class and every effect
* path. An authentic approval of other work, or one given before an effect
* was added, does not bind. An unverified citation counts only as an explicit
* applicability attestation: `for <class>@<digest12>` on the line, matching
* the current scope, so a later effect change invalidates it.
* Caller: bin/gstack-ship-receipt write (APPROVAL_CITATION_MISSING).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { runApprovalScan, type ApprovalHit, type ApprovalScope } from './approval-classes';
import { sha256File } from './headless-artifacts';
import { isUnavailable, loadPolicyFromBase } from './ship-policy';
export interface Citation { raw: string; owner: string; date: string; ref: string; for: Array<{ class: string; digest?: string }> }
export type Resolution =
| { state: 'resolved'; evidence: string; text: string }
| { state: 'rejected'; why: string }
| { state: 'unresolved'; why: string };
export type GhRunner = (args: string[]) => { ok: boolean; out: string; err: string };
export interface CitationContext { runDirs: string[]; maintainers: string[]; gh: GhRunner }
const CLASS_REF = '[a-z][a-z0-9-]*(?:@[0-9a-f]{12,64})?';
const CITATION_RE = new RegExp(`Approved by @?([A-Za-z0-9][\\w.-]*) (\\d{4}-\\d{2}-\\d{2}) via (\\S+)(?: for (${CLASS_REF}(?:,${CLASS_REF})*))?`, 'g');
function validDate(d: string): boolean {
const t = Date.parse(`${d}T00:00:00Z`);
return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === d;
}
/** Every well-formed citation line in `text` (a PR body or --approval values); a bad calendar date is not a citation. */
export function parseCitations(text: string): Citation[] {
const out: Citation[] = [];
for (const m of text.matchAll(CITATION_RE)) {
if (!validDate(m[2]!)) continue;
const ref = m[3]!.replace(/[.,;:)\]>]+$/, '');
const forList = (m[4] ?? '').split(',').filter(Boolean).map(entry => {
const [cls, digest] = entry.split('@');
return digest ? { class: cls!, digest } : { class: cls! };
});
out.push({ raw: m[0], owner: m[1]!.replace(/\.$/, ''), date: m[2]!, ref, for: forList });
}
return out;
}
function readJsonl(file: string): Array<Record<string, any>> {
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
}
function planGate(dir: string, run: Record<string, any>): Resolution {
const entry = run.artifacts?.['plan.md'];
const planFile = path.join(dir, entry?.path ?? 'plan.md');
if (!entry || !fs.existsSync(planFile) || sha256File(planFile) !== entry.sha256) return { state: 'rejected', why: `run ${run.run}: plan.md does not match its manifest hash` };
const gate = run.gate ?? {};
const rev = gate.gate_rev ?? run.gate_rev;
const p1 = readJsonl(path.join(dir, 'decisions.jsonl')).find(d => d.label === 'P1' && d.gate_rev === rev);
const approved = run.status === 'complete' && typeof gate.plan === 'string' && !['c', 'e'].includes(gate.plan) && p1 && p1.chosen === gate.plan && p1.status !== 'pending';
if (!approved) return { state: 'rejected', why: `run ${run.run} holds this plan but its gate is not APPROVED (status ${run.status}, p1 ${p1?.chosen ?? 'unanswered'})` };
return { state: 'resolved', evidence: `plan ${run.run} gate_rev=${rev} p1=${gate.plan} answered_by=${gate.answered_by ?? p1.answered_by ?? 'unrecorded'}`, text: fs.readFileSync(planFile, 'utf8') };
}
/** A plan sha: the plan.md (or reviewed plan) hash of an approved autoplan run among `runDirs`. */
export function resolvePlan(ref: string, runDirs: string[]): Resolution {
const want = ref.toLowerCase();
let rejected: Resolution | undefined;
for (const dir of runDirs) {
let run: Record<string, any>;
try { run = JSON.parse(fs.readFileSync(path.join(dir, 'run.json'), 'utf8')); } catch { continue; }
const hashes = [run.artifacts?.['plan.md']?.sha256, run.plan_reality?.plan_sha256].filter((h): h is string => typeof h === 'string');
if (!hashes.some(h => h.toLowerCase().startsWith(want))) continue;
const r = planGate(dir, run);
if (r.state === 'resolved') return r;
rejected = r;
}
return rejected ?? { state: 'unresolved', why: runDirs.length ? `no --approval-run directory holds a plan with sha ${ref}` : `plan sha ${ref}: pass the autoplan run with --approval-run <dir>` };
}
const GH_URL = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/(\d+)#(?:issuecomment-(\d+)|pullrequestreview-(\d+)|discussion_r(\d+))$/;
/** A comment or review URL: authored by a ship-policy maintainer whose login is the cited owner. */
export function resolveGithub(c: Citation, ctx: CitationContext): Resolution {
const m = GH_URL.exec(c.ref)!;
const [, owner, repo, num, issueComment, review, discussion] = m;
const api = issueComment ? `repos/${owner}/${repo}/issues/comments/${issueComment}`
: review ? `repos/${owner}/${repo}/pulls/${num}/reviews/${review}`
: `repos/${owner}/${repo}/pulls/comments/${discussion}`;
const r = ctx.gh(['api', api]);
if (!r.ok) return { state: 'unresolved', why: `gh api ${api} failed: ${r.err.split('\n')[0] || 'no output'}` };
let body: Record<string, any>;
try { body = JSON.parse(r.out); } catch { return { state: 'unresolved', why: `gh api ${api} returned no JSON` }; }
const login = String(body.user?.login ?? '');
if (!ctx.maintainers.length) return { state: 'unresolved', why: 'the base ship policy lists no maintainers, so the author cannot be classified' };
if (!ctx.maintainers.some(x => x.toLowerCase() === login.toLowerCase())) return { state: 'rejected', why: `${c.ref} is by ${login || 'an unknown author'}, not a ship-policy maintainer` };
if (login.toLowerCase() !== c.owner.toLowerCase()) return { state: 'rejected', why: `the citation names ${c.owner} but ${c.ref} is by ${login}` };
if (review && body.state === 'CHANGES_REQUESTED') return { state: 'rejected', why: `${c.ref} requests changes; it is not an approval` };
return { state: 'resolved', evidence: `${review ? 'review' : 'comment'} by ${login}`, text: String(body.body ?? '') };
}
export function resolveCitation(c: Citation, ctx: CitationContext): Resolution {
if (/^[0-9a-f]{7,64}$/i.test(c.ref)) return resolvePlan(c.ref, ctx.runDirs);
if (GH_URL.test(c.ref)) return resolveGithub(c, ctx);
return { state: 'unresolved', why: `${c.ref} is neither a plan sha nor a GitHub comment or review URL` };
}
/** How the evidence names this exact scope: the token, or the class plus every effect path; null when it does not. */
export function binding(text: string, scope: ApprovalScope): 'token' | 'paths' | null {
const lower = text.toLowerCase();
if (lower.includes(scope.token.toLowerCase())) return 'token';
const named = new RegExp(`(^|[^a-z0-9-])${scope.class}([^a-z0-9-]|$)`).test(lower);
return named && scope.files.every(f => text.includes(f)) ? 'paths' : null;
}
export interface ApprovalRecord {
class: string; scope: string; effects: string[]; citation: 'verified' | 'unverified';
approved_by: string; date: string; via: string; binding: 'token' | 'paths' | 'attested'; evidence: string;
}
export interface MissingApproval { class: string; token: string; files: string[]; reasons: string[] }
function judge(c: Citation, scope: ApprovalScope, res: Resolution): ApprovalRecord | string {
const base = { class: scope.class, scope: scope.token, effects: scope.hits, approved_by: c.owner, date: c.date, via: c.ref };
if (res.state === 'rejected') return `${c.ref}: ${res.why}`;
if (res.state === 'resolved') {
const how = binding(res.text, scope);
if (how) return { ...base, citation: 'verified', binding: how, evidence: res.evidence };
return `${c.ref}: ${res.evidence} names neither ${scope.token} nor ${scope.class} with every effect path (${scope.files.join(', ')}); an approval of other work, or one given before these effects, does not cover them`;
}
const attested = c.for.find(f => f.class === scope.class);
if (attested?.digest && scope.digest.startsWith(attested.digest)) return { ...base, citation: 'unverified', binding: 'attested', evidence: `unresolved: ${res.why}` };
if (attested?.digest) return `${c.ref}: attested for ${scope.class}@${attested.digest.slice(0, 12)} but the effects are now ${scope.token} (changed since the attestation)`;
return `${c.ref}: unresolved (${res.why}); an unverified citation must attest "for ${scope.token}"`;
}
/** One record per scope: a verified citation first, else an attested unverified one; otherwise the scope is missing, with every candidate's reason. */
export function evaluateApprovals(scopes: ApprovalScope[], citations: Citation[], ctx: CitationContext): { records: ApprovalRecord[]; missing: MissingApproval[] } {
const cache = new Map<string, Resolution>();
const resolve = (c: Citation) => { const key = `${c.ref}\0${c.owner}`; if (!cache.has(key)) cache.set(key, resolveCitation(c, ctx)); return cache.get(key)!; };
const records: ApprovalRecord[] = [];
const missing: MissingApproval[] = [];
for (const scope of scopes) {
const candidates = citations.filter(c => !c.for.length || c.for.some(f => f.class === scope.class));
const judged = candidates.map(c => judge(c, scope, resolve(c)));
const pick = judged.find((j): j is ApprovalRecord => typeof j !== 'string' && j.citation === 'verified') ?? judged.find((j): j is ApprovalRecord => typeof j !== 'string');
if (pick) records.push(pick);
else missing.push({ class: scope.class, token: scope.token, files: scope.files, reasons: judged.length ? judged as string[] : ['no "Approved by" line names this class or applies to every class'] });
}
return { records, missing };
}
export function defaultGh(args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
export interface ReceiptApprovalRequest { repoRoot: string; base: string; head: string; citationText: string; runDirs: string[]; gh?: GhRunner }
export type ReceiptApproval =
| { kind: 'skip' }
| { kind: 'ok'; approval: Record<string, unknown>; note?: string }
| { kind: 'invalid'; lines: string[] }
| { kind: 'error'; message: string }
| { kind: 'missing'; missing: MissingApproval[] };
const reportOnly = (hits: ApprovalHit[]) => hits.map(h => `${h.class} ${h.file}:${h.line} (${h.text})`);
/** The receipt's approval object, or the refusal: classes from origin/<base>, effects at `head`, citations from the body and --approval. */
export function approvalForReceipt(req: ReceiptApprovalRequest): ReceiptApproval {
const outcome = runApprovalScan({ repoRoot: req.repoRoot, base: req.base, head: req.head });
if ('unavailable' in outcome) return { kind: 'ok', approval: { source: outcome.source, status: 'unavailable', detail: outcome.unavailable, classes: [] }, note: `APPROVAL_CLASSES: unavailable (${outcome.unavailable}); approval classes were not checked` };
if ('errors' in outcome) return { kind: 'invalid', lines: outcome.errors.map(e => `${e.message} at ${e.path || '<root>'} (${outcome.source})`) };
if ('error' in outcome) return { kind: 'error', message: outcome.error };
const { scan, absent } = outcome;
if (absent && !scan.report_only.length) return { kind: 'skip' };
const policy = loadPolicyFromBase(req.repoRoot, req.base);
const maintainers = isUnavailable(policy) ? [] : policy.policy.maintainers;
const { records, missing } = evaluateApprovals(scan.scopes, parseCitations(req.citationText), { runDirs: req.runDirs, maintainers, gh: req.gh ?? defaultGh });
if (missing.length) return { kind: 'missing', missing };
return { kind: 'ok', approval: { source: scan.source, status: 'checked', classes: records, ...(scan.report_only.length ? { report_only: reportOnly(scan.report_only) } : {}) } };
}
/** The SHIP_RECEIPT summary token: verified, unverified:<classes>, none or unchecked. */
export function approvalSummary(approval: Record<string, any>): string {
if (approval.status === 'unavailable') return 'unchecked';
const classes = (approval.classes ?? []) as ApprovalRecord[];
if (!classes.length) return 'none';
const unverified = classes.filter(c => c.citation === 'unverified').map(c => c.class);
return unverified.length ? `unverified:${unverified.join(',')}` : 'verified';
}
+249
View File
@@ -0,0 +1,249 @@
/**
* approval-classes — changes a repository says need the owner's approval
* (field list 2, W2). `.gstack/approval-classes.json`, read only from the
* committed copy on `origin/<base>` so a branch cannot declare or drop its
* own classes, lists named classes; each matches changed files by `paths`
* globs, added lines by literal `patterns`, and the ship policy's
* `migrations` files when `migrations: true`, minus its `allowed_paths`.
*
* Every declared class with hits gets a scope: the sorted effects (a path hit
* is the file and its blob at the scanned head; a pattern hit is the file and
* the added line's text) and a digest over them, printed as the token
* `<class>@<digest12>`. An approval citation binds to that token (or to the
* class name plus every effect path), so an approval for other work, or one
* given before an effect was added, does not cover the change
* (lib/approval-citations.ts). Built-in detectors (the ship policy's
* migration files when no declared class claims them) only report.
* Callers: bin/gstack-approval-classes, bin/gstack-ship-receipt.
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { addedLines } from './banned-terms';
import type { ResultCodeName } from './result-codes';
import { assertInsideRoot, containedRelative, globToRegExp, isUnavailable, loadPolicyFromBase, matchesAny } from './ship-policy';
export const APPROVAL_CLASSES_PATH = '.gstack/approval-classes.json';
export const APPROVAL_CLASSES_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#approval-classes';
const NAME_RE = /^[a-z][a-z0-9-]{0,39}$/;
const MAX_CLASSES = 32;
const MAX_LIST = 64;
const MAX_PATTERN = 200;
const CLASS_KEYS = ['name', 'description', 'paths', 'patterns', 'allowed_paths', 'migrations'];
export interface ApprovalClass { name: string; description?: string; paths?: string[]; patterns?: string[]; allowed_paths?: string[]; migrations?: boolean }
export interface ApprovalClassesConfig { $schema?: string; classes: ApprovalClass[] }
export interface ApprovalClassesError { code: ResultCodeName; path: string; message: string }
export interface ApprovalHit { class: string; file: string; line: number; via: 'path' | 'pattern' | 'migration'; text: string; declared: boolean; effect: string }
export interface ApprovalScope { class: string; digest: string; token: string; effects: string[]; files: string[]; hits: string[] }
export interface ApprovalScan { source: string; classes: number; hits: ApprovalHit[]; report_only: ApprovalHit[]; scopes: ApprovalScope[] }
export interface MigrationRule { dir: string; id: RegExp }
export function exampleApprovalClassesJson(): string {
return JSON.stringify({
$schema: APPROVAL_CLASSES_SCHEMA,
classes: [
{ name: 'upgrade-data-rewrite', description: 'a migration that rewrites existing rows when a user upgrades', migrations: true },
{ name: 'trust-visibility', description: 'who can see or act on data', paths: ['src/auth/**', 'src/visibility/**'], patterns: ['visibility:'], allowed_paths: ['test/**'] },
{ name: 'data-repair', description: 'a command that rewrites user data; explicit and preview-first, never run at upgrade', paths: ['src/commands/repair/**'] },
{ name: 'default-change', description: 'a default that changes behavior for existing users', patterns: ['DEFAULT_'], allowed_paths: ['test/**', 'docs/**'] },
],
}, null, 2) + '\n';
}
function listErrors(at: string, value: unknown, kind: 'glob' | 'pattern', errors: ApprovalClassesError[]): void {
if (!Array.isArray(value)) { errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: at, message: `must be an array of ${kind}s` }); return; }
if (value.length > MAX_LIST) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: at, message: `more than ${MAX_LIST} entries` });
value.forEach((v, i) => {
let why: string | null = null;
if (typeof v !== 'string' || !v.trim()) why = 'must be a non-empty string';
else if (kind === 'pattern') why = v.length > MAX_PATTERN ? `longer than ${MAX_PATTERN} characters` : /[\0\r\n]/.test(v) ? 'contains a control character' : null;
else {
why = containedRelative(v);
if (!why) try { globToRegExp(v); } catch (e: any) { why = e.message; }
}
if (why) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}[${i}]`, message: `${JSON.stringify(v)} ${why}` });
});
}
function classErrors(c: unknown, at: string, seen: Set<string>, errors: ApprovalClassesError[]): void {
if (!c || typeof c !== 'object' || Array.isArray(c)) { errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: at, message: 'must be an object' }); return; }
const cls = c as Record<string, unknown>;
for (const key of Object.keys(cls)) if (!CLASS_KEYS.includes(key)) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}.${key}`, message: `unknown key; valid keys: ${CLASS_KEYS.join(', ')}` });
if (typeof cls.name !== 'string' || !NAME_RE.test(cls.name)) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}.name`, message: 'must be lowercase letters, digits and dashes (1-40 characters, starting with a letter)' });
else if (seen.has(cls.name)) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}.name`, message: `duplicate class ${cls.name}` });
else seen.add(cls.name);
if (cls.description !== undefined && (typeof cls.description !== 'string' || cls.description.length > MAX_PATTERN)) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}.description`, message: `must be a string of at most ${MAX_PATTERN} characters` });
if (cls.migrations !== undefined && typeof cls.migrations !== 'boolean') errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: `${at}.migrations`, message: 'must be true or false' });
if (cls.paths !== undefined) listErrors(`${at}.paths`, cls.paths, 'glob', errors);
if (cls.allowed_paths !== undefined) listErrors(`${at}.allowed_paths`, cls.allowed_paths, 'glob', errors);
if (cls.patterns !== undefined) listErrors(`${at}.patterns`, cls.patterns, 'pattern', errors);
const matches = (Array.isArray(cls.paths) && cls.paths.length > 0) || (Array.isArray(cls.patterns) && cls.patterns.length > 0) || cls.migrations === true;
if (!matches) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: at, message: 'matches nothing: give paths, patterns or migrations: true' });
}
/** Schema and containment: a non-empty `classes` list of uniquely named classes, each with something to match. Unknown keys are errors. */
export function validateApprovalClasses(raw: unknown): ApprovalClassesError[] {
const errors: ApprovalClassesError[] = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'APPROVAL_CLASSES_INVALID', path: '', message: 'must be a JSON object' }];
const obj = raw as Record<string, unknown>;
for (const key of Object.keys(obj)) if (key !== '$schema' && key !== 'classes') errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: key, message: 'unknown key; valid keys: classes' });
if (!Array.isArray(obj.classes) || obj.classes.length === 0) return [...errors, { code: 'APPROVAL_CLASSES_INVALID', path: 'classes', message: 'must be a non-empty array' }];
if (obj.classes.length > MAX_CLASSES) errors.push({ code: 'APPROVAL_CLASSES_INVALID', path: 'classes', message: `more than ${MAX_CLASSES} classes` });
const seen = new Set<string>();
obj.classes.forEach((c, i) => classErrors(c, `classes[${i}]`, seen, errors));
return errors;
}
export type ApprovalLoad =
| { config: ApprovalClassesConfig; source: string }
| { config: null; source: string; errors: ApprovalClassesError[] }
| { config: null; source: string; absent: true }
| { config: null; source: string; unavailable: string };
function git(repoRoot: string, args: string[], timeout = 30_000): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '' };
}
function parseConfig(text: string, source: string): ApprovalLoad {
let parsed: unknown;
try { parsed = JSON.parse(text); } catch (e: any) { return { config: null, source, errors: [{ code: 'APPROVAL_CLASSES_INVALID', path: '', message: `not valid JSON: ${e.message}` }] }; }
const errors = validateApprovalClasses(parsed);
return errors.length ? { config: null, source, errors } : { config: parsed as ApprovalClassesConfig, source };
}
/** `origin/<base>` for a branch name; a leading `origin/` is not doubled. */
export function baseRef(base: string): string {
return `origin/${base.replace(/^origin\//, '')}`;
}
/** The committed copy on `origin/<base>`; never the working tree. An unresolvable base is `unavailable`, never a silent pass. */
export function loadApprovalClasses(repoRoot: string, base: string): ApprovalLoad {
const ref = baseRef(base);
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out.trim()) return { config: null, source: `${ref}:${APPROVAL_CLASSES_PATH}`, unavailable: `${ref} is not a resolvable commit` };
const commit = sha.out.trim();
const source = `${ref}@${commit.slice(0, 12)}:${APPROVAL_CLASSES_PATH}`;
if (!git(repoRoot, ['cat-file', '-e', `${commit}:${APPROVAL_CLASSES_PATH}`]).ok) return { config: null, source, absent: true };
const show = git(repoRoot, ['show', `${commit}:${APPROVAL_CLASSES_PATH}`]);
return show.ok ? parseConfig(show.out, source) : { config: null, source, errors: [{ code: 'APPROVAL_CLASSES_INVALID', path: '', message: 'unreadable' }] };
}
/** The working-tree file, for `validate` while editing it (enforcement never reads it). */
export function loadWorkingApprovalClasses(repoRoot: string): ApprovalLoad {
const escape = assertInsideRoot(repoRoot, APPROVAL_CLASSES_PATH);
if (escape) return { config: null, source: APPROVAL_CLASSES_PATH, errors: [{ code: 'APPROVAL_CLASSES_INVALID', path: APPROVAL_CLASSES_PATH, message: escape }] };
const file = path.join(repoRoot, APPROVAL_CLASSES_PATH);
if (!fs.existsSync(file)) return { config: null, source: APPROVAL_CLASSES_PATH, absent: true };
return parseConfig(fs.readFileSync(file, 'utf8'), APPROVAL_CLASSES_PATH);
}
/** The ship policy's migration rule on the same reviewed base, or null. */
export function baseMigrationRule(repoRoot: string, base: string): MigrationRule | null {
const load = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(load) || load.errors.length || !load.policy.migrations) return null;
return { dir: load.policy.migrations.dir.replace(/\/+$/, ''), id: new RegExp(load.policy.migrations.id) };
}
export interface ChangedFile { file: string; status: string; blob: string; added: Array<{ line: number; text: string }> }
/** Files changed between `diffBase` and `head` (the working tree when `head` is omitted), with added lines and blob ids. */
export function changedFiles(repoRoot: string, diffBase: string, head?: string): ChangedFile[] | { error: string } {
const range = head ? [diffBase, head] : [diffBase];
const names = git(repoRoot, ['diff', '--name-status', '-z', '--no-renames', ...range], 120_000);
const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-renames', ...range], 120_000);
if (!names.ok || !diff.ok) return { error: `git diff ${range.join(' ')} failed` };
const added = addedLines(diff.out);
const parts = names.out.split('\0').filter(Boolean);
const out: ChangedFile[] = [];
for (let i = 0; i + 1 < parts.length; i += 2) {
const status = parts[i]!.slice(0, 1);
const file = parts[i + 1]!;
let blob = 'deleted';
if (status !== 'D') {
const r = head ? git(repoRoot, ['rev-parse', `${head}:${file}`]) : git(repoRoot, ['hash-object', '--', file]);
blob = r.ok ? r.out.trim() : 'unreadable';
}
out.push({ file, status, blob, added: added.get(file) ?? [] });
}
return out;
}
function isMigration(rule: MigrationRule | null, file: string): boolean {
return !!rule && file.startsWith(`${rule.dir}/`) && rule.id.test(path.posix.basename(file));
}
const STATUS_WORD: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', T: 'type changed' };
function classHits(cls: ApprovalClass, files: ChangedFile[], migrations: MigrationRule | null): ApprovalHit[] {
const hits: ApprovalHit[] = [];
const pathRes = (cls.paths ?? []).map(globToRegExp);
for (const f of files) {
if (f.file === APPROVAL_CLASSES_PATH || matchesAny(cls.allowed_paths ?? [], f.file)) continue;
const byMigration = cls.migrations === true && isMigration(migrations, f.file);
if (byMigration || pathRes.some(re => re.test(f.file))) {
hits.push({ class: cls.name, file: f.file, line: f.added[0]?.line ?? 1, via: byMigration ? 'migration' : 'path', text: STATUS_WORD[f.status] ?? f.status, declared: true, effect: `path\0${f.file}\0${f.blob}` });
}
for (const pattern of cls.patterns ?? []) for (const l of f.added) {
if (l.text.includes(pattern)) hits.push({ class: cls.name, file: f.file, line: l.line, via: 'pattern', text: l.text.trim().slice(0, 160), declared: true, effect: `line\0${f.file}\0${l.text.trim()}` });
}
}
return hits;
}
/** The scope token binds a citation to these exact effects: any added, removed or edited effect changes the digest. */
export function scopeOf(name: string, hits: ApprovalHit[]): ApprovalScope {
const effects = [...new Set(hits.map(h => h.effect))].sort();
const digest = createHash('sha256').update(`${name}\n${effects.join('\n')}`).digest('hex');
return { class: name, digest, token: `${name}@${digest.slice(0, 12)}`, effects, files: [...new Set(hits.map(h => h.file))].sort(), hits: hits.map(h => `${h.file}:${h.line}`) };
}
/** Declared hits, report-only built-in hits, and one scope per declared class with hits. */
export function scanApprovalClasses(config: ApprovalClassesConfig | null, source: string, files: ChangedFile[], migrations: MigrationRule | null): ApprovalScan {
const classes = config?.classes ?? [];
const hits = classes.flatMap(cls => classHits(cls, files, migrations));
const claimsMigrations = classes.some(c => c.migrations === true);
const report_only: ApprovalHit[] = claimsMigrations ? [] : files.filter(f => isMigration(migrations, f.file)).map(f => ({
class: 'migrations', file: f.file, line: f.added[0]?.line ?? 1, via: 'migration' as const, text: STATUS_WORD[f.status] ?? f.status, declared: false, effect: `path\0${f.file}\0${f.blob}`,
}));
const scopes = classes.map(cls => hits.filter(h => h.class === cls.name)).filter(own => own.length).map(own => scopeOf(own[0]!.class, own));
return { source, classes: classes.length, hits, report_only, scopes };
}
export interface ScanRequest { repoRoot: string; base: string; head?: string; diffBase?: string }
export type ScanOutcome =
| { scan: ApprovalScan; absent: boolean }
| { errors: ApprovalClassesError[]; source: string }
| { unavailable: string; source: string }
| { error: string };
/** Load the base's classes and migration rule, diff since the merge base, scan. Skips the diff when nothing could match. */
export function runApprovalScan(req: ScanRequest): ScanOutcome {
const load = loadApprovalClasses(req.repoRoot, req.base);
if ('unavailable' in load) return { unavailable: load.unavailable, source: load.source };
if ('errors' in load) return { errors: load.errors, source: load.source };
const migrations = baseMigrationRule(req.repoRoot, req.base);
const absent = 'absent' in load;
if (absent && !migrations) return { scan: scanApprovalClasses(null, load.source, [], null), absent };
const mergeBase = req.diffBase ? undefined : git(req.repoRoot, ['merge-base', baseRef(req.base), req.head ?? 'HEAD']);
const diffBase = req.diffBase ?? (mergeBase?.ok ? mergeBase.out.trim() : undefined);
if (!diffBase) return { error: `no merge base between ${baseRef(req.base)} and ${req.head ?? 'HEAD'}; pass --diff-base <sha>` };
const files = changedFiles(req.repoRoot, diffBase, req.head);
if ('error' in files) return files;
return { scan: scanApprovalClasses(load.config, load.source, files, migrations), absent };
}
export const CITATION_TEMPLATE = 'Approved by <owner> <YYYY-MM-DD> via <plan sha|comment or review URL|ref>';
export function renderApprovalScan(scan: ApprovalScan, absent: boolean): string {
const lines = [
...scan.hits.map(h => `APPROVAL_CLASS: ${h.class} ${h.file}:${h.line} (${h.via}) — ${h.text}`),
...scan.report_only.map(h => `APPROVAL_CLASS: ${h.class} ${h.file}:${h.line} (built-in, report-only) — ${h.text}`),
...scan.scopes.map(s => `APPROVAL_SCOPE: ${s.token} effects=${s.effects.length} files=${s.files.join(',')}; cite: ${CITATION_TEMPLATE} for ${s.token}`),
];
const summary = absent ? `none (no ${APPROVAL_CLASSES_PATH} on the base)` : scan.hits.length ? `found=${scan.hits.length}` : 'ok';
lines.push(`APPROVAL_CLASSES: ${summary} classes=${scan.classes} report_only=${scan.report_only.length} source=${scan.source}`);
return lines.join('\n') + '\n';
}
+3
View File
@@ -82,6 +82,8 @@ export interface ShipReceipt {
thread?: string; tree?: string; gate_reuse?: string; policy?: string; queue_mode?: string; preregistration?: string; history?: string;
// C-pregate (C8): the pre-gate's verdict against this head and its remote obligations (publication requires their receipts).
pregate?: string; requires_remote?: string[];
/** Approval classes (lib/approval-citations.ts): source, status, one record per declared class with hits, report-only built-in hits. */
approval?: Record<string, unknown>;
// C-pregate (C6): issue links the body carries, by intent.
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
@@ -199,6 +201,7 @@ export const ARTIFACT_SCHEMAS = {
session_kind: str, artifacts_consumed: { ...str, description: '<n>/<m>' }, run: str,
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } },
},
},
+26
View File
@@ -736,6 +736,32 @@ export const RESULT_CODES = {
summary: 'an added or changed test line compares an elapsed wall-clock or CPU time against a bound, or sleeps and then asserts (a warning, not a block)',
fix: 'assert on the outcome, an injected clock or a forced deadline instead; when the bound is the contract, add `gstack-timing-ok: <reason>` in a comment on the line or the line above',
},
// Field list 2, W2 (approval classes) and W3 (state-machine round-trip matrix).
APPROVAL_CLASSES_INVALID: {
anchor: 'approval-classes-invalid',
summary: '`.gstack/approval-classes.json` is malformed, a class matches nothing, or a path escapes the repository',
fix: 'keep `classes` a non-empty list of uniquely named classes, each with `paths`, `patterns` or `migrations: true`, and every glob repo-relative (`gstack-approval-classes validate` checks the working-tree copy)',
},
APPROVAL_CLASS_FOUND: {
anchor: 'approval-class-found',
summary: 'the branch changes something a declared approval class covers',
fix: 'get the owner\'s approval and put `Approved by <owner> <YYYY-MM-DD> via <plan sha|comment or review URL> for <class>@<digest12>` in the PR body, using the APPROVAL_SCOPE token the check printed',
},
APPROVAL_CITATION_MISSING: {
anchor: 'approval-citation-missing',
summary: 'a declared approval class has no citation that resolves and binds to its current effects, so no receipt was written',
fix: 'add an `Approved by` line whose plan (with --approval-run <dir>) or maintainer comment names the APPROVAL_SCOPE token or the class and every effect path; for evidence gstack cannot read, attest it with `for <class>@<digest12>` on the line',
},
APPROVAL_SOURCE_UNAVAILABLE: {
anchor: 'approval-source-unavailable',
summary: 'origin/<base> is not a resolvable commit, so the committed .gstack/approval-classes.json cannot be read and nothing was checked',
fix: 'git fetch origin <base> (classes are never read from the working tree)',
},
STATE_MATRIX_INCOMPLETE: {
anchor: 'state-matrix-incomplete',
summary: 'the state-machines specialist output lacks a kind × owner action cell, maps a cell to a test that does not exist, or leaves an untested cell without a CRITICAL finding',
fix: 'rerun the state-machines specialist on the same diff; its review coverage stays incomplete until gstack-state-matrix check passes',
},
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+3 -1
View File
@@ -7,13 +7,14 @@
* thread id against an owner-maintained thread cohort).
*/
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
import { approvalSummary } from './approval-citations';
import { renderIssueLines } from './issue-links';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'pregate', 'requires_remote', 'issues',
'pregate', 'requires_remote', 'issues', 'approval',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -78,6 +79,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`);
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
return `SHIP_RECEIPT: ${parts.join(' ')}`;
}