Files
gstack/lib/ship-receipt-prose.ts
T
Garry Tanandcapy-ai[bot] b1f78301db The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)
* Let an owner recorded approval close a gate finding in unattended ship (GST-2491)

An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491)

A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: s11/adversarial-native#2 reader=adversarial-native
Review-Finding: s11/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Require the lander to accept each owner gate disposition before merging (GST-2491)

Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Regenerate the result-code anchors for the gate-disposition refusal (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491)

The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491)

Review-Finding: s11/outside-voice#3 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-12 01:49:08 +00:00

166 lines
10 KiB
TypeScript

/**
* The receipt-versus-prose number check (/ship Step 19, docs/ship-policy.md#ship-receipt):
* a PR body that quotes a counter the receipt also records (review passes,
* deferred findings, gate case counts) must quote the receipt's number.
* `gstack-ship-receipt read --check-prose` runs it; a disagreement is
* RECEIPT_PROSE_MISMATCH. Prose that quotes no receipt counter is not a claim.
*/
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
import { RECEIPT_FENCE } from './ship-receipt';
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
import { ruledNoteText, type RuledNote } from './ruled-notes';
import { gateDispositionText, type GateDisposition } from './gate-dispositions';
import { laneExceptionBodyLines } from './test-lane-exception';
export interface ProseClaim { field: string; prose: number; receipt: number }
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
/** Integer leaves of the receipt under the counter-bearing keys, as `parent.key` → value. */
export function receiptCounters(receipt: Record<string, unknown>): Map<string, number> {
const out = new Map<string, number>();
const walk = (value: unknown, prefix: string) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
for (const [key, leaf] of Object.entries(value as Record<string, unknown>)) {
if (Number.isInteger(leaf)) out.set(prefix ? `${prefix}.${key}` : key, leaf as number);
else walk(leaf, prefix ? `${prefix}.${key}` : key);
}
};
for (const key of ['review_loop', 'gate', 'ci']) walk(receipt[key], key);
return out;
}
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
const fenceClose = (fence: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`);
/**
* The body without its closed code fences (GST-984): pasted test output (`113 pass, 0 fail`) and quoted code
* (`the first 35 failing`) are evidence, not this PR's counters. The fence rules are withoutApprovalsBlocks' (a fence
* closes only on a run of its own character at least as long as the opener with nothing after it). A backtick run with
* another backtick later on its line (```npm test``` inline code) opens no fence, as in CommonMark. A fence that never
* closes stays prose, and so does the rest of the body, so an unclosed opener cannot hide the claims after it or let a
* shorter fence inside it hide them. A marker line takes each fence's place so the text on either side never joins
* into a claim.
*/
function withoutFences(body: string): string {
const lines = body.split(/(?<=\n)/);
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!.replace(/\r?\n$/, '');
const open = FENCE_OPEN.exec(line);
if (!open || (open[1]![0] === '`' && line.slice(open[0].length).includes('`'))) { out.push(lines[i]!); continue; }
const close = fenceClose(open[1]!);
const end = lines.findIndex((l, j) => j > i && close.test(l.replace(/\r?\n$/, '')));
if (end < 0) return out.join('') + lines.slice(i).join('');
out.push('(code fence)\n');
i = end;
}
return out.join('');
}
/**
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
* heading, the Date | Asked | Exact words table and its `Source: gstack-approvals record for …` footer. Those rows
* quote the owner's words (`one Step 11 pass`), not this PR's counters (GST-412). A heading inside a code fence is
* not a heading, with the fence rules of splitSections in lib/gstack-post.ts (a fence closes only on a run of its own
* character at least as long as the opener with nothing after it), and CRLF reads as LF. A heading not followed by
* exactly that table and footer, a row the renderer would not write (six cells, a date first), and any text after
* the footer stay prose. A marker line takes the block's place so the text on either side never joins into a claim.
*/
function withoutApprovalsBlocks(body: string): string {
const lines = body.split(/(?<=\n)/);
const bare = (i: number) => (lines[i] ?? '').replace(/\r?\n$/, '');
const skipBlank = (i: number) => { while (i < lines.length && bare(i).trim() === '') i++; return i; };
const blockEnd = (heading: number): number | undefined => {
let i = skipBlank(heading + 1);
if (bare(i) !== APPROVALS_TABLE_HEADER || !/^\|(?:---\|)+$/.test(bare(i + 1))) return undefined;
for (i += 2; bare(i).startsWith('|'); i++) if (!/^\| \d{4}-\d{2}-\d{2} \|/.test(bare(i)) || bare(i).split(/(?<!\\)\|/).length !== 8) return undefined;
i = skipBlank(i);
return bare(i).startsWith('Source: gstack-approvals record for ') && bare(i).endsWith(APPROVALS_FOOTER_END) ? i : undefined;
};
const out: string[] = [];
let fence: string | null = null;
for (let i = 0; i < lines.length; i++) {
const line = bare(i);
if (fence !== null) {
if (fenceClose(fence).test(line)) fence = null;
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
else if (/^ {0,3}## Approvals\s*$/.test(line)) {
const end = blockEnd(i);
if (end !== undefined) { out.push('(gstack-approvals block)\n'); i = end; continue; }
}
out.push(lines[i]!);
}
return out.join('');
}
/**
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …`, `Gate disposition: …`, `Mechanical retake: …` and `Test-lane exception: …` line (GST-1537, GST-2491, GST-1560, GST-1595). write and the receipt check want each word for word,
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
* prose. A marker line takes its place so the text on either side never joins into a claim.
*/
function withoutExceptionLines(body: string, receipt: Record<string, unknown>): string {
const lines = new Set<string>();
const loop = receipt.review_loop_exception === undefined ? undefined : parseReviewLoopException(receipt.review_loop_exception);
if (loop?.ok) lines.add(reviewLoopExceptionLine(loop.exception));
const docs = receipt.docs === undefined ? undefined : parseReceiptDocs(receipt.docs);
const docsLine = docs?.ok ? docsExceptionLine(docs.docs) : null;
if (docsLine) lines.add(docsLine);
if (Array.isArray(receipt.ruled_notes)) for (const n of receipt.ruled_notes) lines.add(`Ruled note: ${ruledNoteText(n as RuledNote)}`);
if (Array.isArray(receipt.gate_dispositions)) for (const d of receipt.gate_dispositions) lines.add(`Gate disposition: ${gateDispositionText(d as GateDisposition)}`);
if (Array.isArray(receipt.mechanical)) for (const l of receipt.mechanical) if (typeof l === 'string') lines.add(l);
for (const l of laneExceptionBodyLines(receipt.ci_carried)) lines.add(l);
if (!lines.size) return body;
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
}
/**
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
* without its receipt block, its code fences, its rendered Approvals block and its exception lines), where `<name>` is a counter's leaf key with `_`
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through`
* is not a `pass` claim), compared with the receipt. A leaf key shared by
* several counters (`pass` under every gate lane, `passed` under gate and ci)
* names any of them: the claim matches when its number equals one of those
* counters, and a mismatch names the first of them. A number that is the value
* of a `key=` or `key:` pair is that key's claim alone, never the start of a
* `<n> <name>` claim about the next key (#3513).
*/
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
const prose = withoutExceptionLines(withoutApprovalsBlocks(withoutFences(body.replace(BLOCK_RE, ''))), receipt);
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
for (const [field, value] of receiptCounters(receipt)) {
const leaf = field.split('.').at(-1)!;
byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), { field, value }]);
}
const pattern = (leaf: string) => leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
// The number-then-name branch must not start on a `key=value` or `key: value` pair's value: in `passes=2 blocking_found=1`
// and `passes: 2 blocking_found: 1` the 2 is passes' claim, not a "2 blocking_found" one. The offsets of every counter
// key's value are collected first, whatever the whitespace around the separator, and a claim starting there is skipped.
const values = new Set<number>();
for (const leaf of byLeaf.keys()) for (const m of prose.matchAll(new RegExp(`(?<![\\w-])${pattern(leaf)}\\s*[=:]\\s*(?=\\d)`, 'gi'))) values.add(m.index + m[0].length);
const mismatches: ProseClaim[] = [];
let checked = 0;
for (const [leaf, counters] of byLeaf) {
const name = pattern(leaf);
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|(?<![=:\\w.-])(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
for (let m = re.exec(prose); m; m = re.exec(prose)) {
// A skipped value hands the scan back right after its digits, so the key that follows still makes its own pair claim.
if (m[2] !== undefined && values.has(m.index)) { re.lastIndex = m.index + m[2].length; continue; }
checked++;
const n = Number(m[1] ?? m[2]);
if (!counters.some(c => c.value === n)) mismatches.push({ field: counters[0]!.field, prose: n, receipt: counters[0]!.value });
}
}
return { checked, mismatches };
}
export function proseLine(check: ProseCheck): string {
if (!check.checked) return 'PROSE: no receipt counter claims in the body';
if (!check.mismatches.length) return `PROSE: ${check.checked} receipt counter claim${check.checked === 1 ? '' : 's'} in the body, all match`;
return 'PROSE: mismatch ' + check.mismatches.map(m => `${m.field} body=${m.prose} receipt=${m.receipt}`).join('; ');
}