ship finish: an owner-ruled note closes its finding, and every ruling is listed (GST-1537) (#4050)

* ship finish: an owner-ruled note closes its finding, and every ruling is listed (GST-1537)

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

* test(windows): exclude ship-finish-ruled-notes from the Windows lanes like its ship-bin-fixture siblings (GST-1537)

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

* ship: keep the pinned Step 9 brief unchanged; the PR body's Pre-Landing Review lists ruled notes (GST-1537)

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

* fix: pre-landing review fixes

Review-Finding: p1/specialist-batched#1 reader=state-machines
Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: bind each ruled note to the stop head it was ruled at (GST-1537)

Review-Finding: p2/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: p1/specialist-batched#2 reader=testing
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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 02:08:16 +00:00
1 parent eaf1701da2
commit cf21a7fbef
16 files changed
+379 -16

No files matched your search

+33 -4
View File
@@ -7,6 +7,8 @@
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
* gstack-approvals render [--no-heading] [--full] [--branch <name>]
* gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled> [--json]
* gstack-approvals rulings [--branch <name>]
* gstack-approvals list [--json] [--branch <name>]
* gstack-approvals sync [--branch <name>]
* gstack-approvals path [--branch <name>]
@@ -18,9 +20,11 @@
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
* lib/approval-record.ts.
*/
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
@@ -29,6 +33,12 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
rule --finding <fingerprint> --head <the stop head the owner ruled at> --reason <why it ships as a note, one line>
--words <the owner's exact words> --by <who ruled>
the owner's ruling that a review finding ships as a disclosed note (docs/ship-policy.md#ruled-notes); finish
counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body
list it; refused inside this checkout's open /ship run
rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry
render [--no-heading] [--full] the ## Approvals section for a PR body or report; a public repository, or one whose
visibility gh cannot read, gets only "Approved: yes|no (record <id>)", because a PR description keeps
its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private
@@ -43,7 +53,7 @@ Example:
${exitTableHelp()}`;
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head']);
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
const flags: Record<string, string> = {};
@@ -111,9 +121,23 @@ function main(argv: string[]): number {
const json = bools.has('--json');
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
if (cmd === 'add' || cmd === 'import') {
if (cmd === 'add' || cmd === 'import' || cmd === 'rule') {
let rows;
if (cmd === 'add') {
if (cmd === 'rule') {
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
const finding = (flags['--finding'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim();
if (!finding || /\s/.test(finding) || !reason) return invalid('rule needs --finding <the finding\'s fingerprint, no spaces> and --reason <why it ships as a note>');
if (/[\x00-\x1f\x7f]/.test(reason)) return invalid('--reason is one line: no newline or control character');
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
if (!flags['--head'] || head.status !== 0) return invalid('rule needs --head <the stop head the owner ruled at, a commit in this repository>');
if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a ruling is the owner\'s, so --by names who ruled');
const open = openShipRun(process.cwd());
if (open) return invalid(`/ship run ${open} is open on this checkout: a ruling is the owner's, never the lane's own runner's; record it from the owner's session`);
const built = buildRow({ ...ruledNoteRow(finding, head.stdout.trim(), reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
} else if (cmd === 'add') {
if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' });
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
@@ -126,7 +150,7 @@ function main(argv: string[]): number {
const res = appendRows(loc, rows);
if ('code' in res) return fail(cmd, res);
if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n');
else if (cmd === 'add') process.stdout.write(`APPROVAL_RECORDED: ${rows[0]!.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
else if (cmd !== 'import') process.stdout.write(`APPROVAL_RECORDED: ${rows[0]!.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
else process.stdout.write(`APPROVAL_IMPORTED: n=${res.added.length} skipped=${res.skipped} ${recorded(loc)}\n`);
if (res.mirrorError) return fail(cmd, res.mirrorError);
return EXIT.ok;
@@ -145,6 +169,11 @@ function main(argv: string[]): number {
if (read.state === 'unavailable') return fail(cmd, { code: 'APPROVAL_RECORD_UNAVAILABLE', message: `no record at ${loc.file} and no mirror` });
return EXIT.ok;
}
if (cmd === 'rulings') {
const notes = ruledNotes(readRecord(loc).rows);
process.stdout.write(notes.length ? notes.map(n => `Ruled note: ${ruledNoteText(n)}\n`).join('') : 'RULED_NOTES: none\n');
return EXIT.ok;
}
if (cmd === 'sync') {
const s = syncRecord(loc);
process.stdout.write(`APPROVAL_SYNCED: rows=${s.rows} ${recorded(loc)}\n`);
+5 -1
View File
@@ -56,6 +56,7 @@ import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes';
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
@@ -260,6 +261,9 @@ function cmdWrite(rest: string[], json: boolean): number {
const line = reviewLoopExceptionLine(loopException.exception);
if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`);
}
const ruledNotes = readRuledNotes(repoRoot);
const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
const declared = (flag(rest, '--declare-risk') ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (rest.includes('--declare-risk') && (!declared.length || declared.some(c => !(RISK_CLASSES as readonly string[]).includes(c)))) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--declare-risk ${flag(rest, '--declare-risk') ?? ''}: each class is one of ${RISK_CLASSES.join(', ')}; nothing written`) + '\n'); return EXIT.fail; }
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head, declared as RiskClass[]);
@@ -315,7 +319,7 @@ function cmdWrite(rest: string[], json: boolean): number {
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, approval: approval.kind === 'ok' ? approval.approval : undefined,
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, ruled_notes: ruledNotes.length ? ruledNotes : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
review_exclude: receiptExclude(review.exclude),
no_stop: noStopArg(flag(rest, '--no-stop')),
+1
View File
@@ -0,0 +1 @@
- **A finding the owner rules a disclosed note no longer keeps a granted fix-check from counting, and every ruling is listed.** On qm#3122 at 532cc5c the owner ruled two CRITICALs notes, not fixes. The runner logged them as `skipped`, `finish` counted a skipped CRITICAL as open, so the granted fix-check row (its own findings all below the floor) never read CURRENT and `finish` stopped with "behavior changed since review". The owner now records each ruling with `gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled>`: an approved approval row with the ruler, the head, the reason and the time. `rule` refuses a head that is not a commit, a multi-line reason, a `--by` naming gstack and any ruling inside the checkout's open /ship run, and `add` refuses the ruling scope. `finish` reads rulings from the approval record only (a `ruled` field or `action: "ruled"` on the lane's own review-log row is ignored), treats a ruled finding as closed when the ruling's head lies inside the fix-check's range, whichever came first, and prints each as `RULED_NOTE:`; a ruling at an earlier head is listed as not applied. A skipped CRITICAL with no ruling stays open. `gstack-approvals rulings` prints the `Ruled note: …` lines for the Pre-Landing Review and the PR body; the receipt records them as `ruled_notes`, `gstack-ship-receipt write --body` refuses a body missing one, and the READY block prints each. See docs/ship-policy.md#ruled-notes. (GST-1537)
+2 -1
View File
@@ -95,7 +95,8 @@ incomplete coverage, which blocks until the reviewer is rerun.
the diff is `fix`; every below-floor finding is a `note`.
3. **Two rounds is the cap** (R51): a full pass, a scoped pass
([below](#scoped-pass)) or a fix-check is a round. After round 2 the run
finishes with its open notes listed in the PR body and receipt, or stops for
finishes with its open notes listed in the PR body and receipt (an owner-ruled
note is closed and listed: [ruled notes](ship-policy.md#ruled-notes)), or stops for
the owner when a blocking finding is open, or was fixed in round 2 and is
unverified, or a round-2 fix edited a gate-class path, naming it (path,
severity, problem). The cap limits fix rounds, not Step 11's voices: on gate
+35
View File
@@ -1608,6 +1608,41 @@ construction:
the run and names the finding (path, severity, problem) for the owner instead
of a third round; the owner may grant one scoped fix-check.
<a id="ruled-notes"></a>
**Ruled notes (GST-1537).** R51's other answer to an open finding is the owner's
ruling that it ships as a disclosed note, with the note listed. The owner records
each ruling, one finding per row, through the approval record, outside the lane's
/ship run, naming the stop head it ruled at (the commit a `review_loop_grant` names):
```bash
gstack-approvals rule --finding '<the finding's fingerprint>' --head <stop head> \
--reason '<why it ships as a note, one line>' --words '<the owner's exact words>' --by '<who ruled>'
```
The row is an approved `gstack-approvals` row with scope `ruled_note <fingerprint> <head>`
and ask `ruled_note <fingerprint> at <head>: <reason>`, with the head's full sha, so
two findings ruled with the same reason in one reply get two rows, and a ruling at
a later head is a new row. Its `by` is the ruler and its `recorded_at` the time.
`rule` refuses a `--head` that is not a commit in the repository, a reason with a
newline or control character, a `--by` naming gstack, and any ruling while this
checkout holds an open /ship run's marker (the lane's own runner). `add` refuses a
`ruled_note` scope, so `rule` is the only path. A review-log field never makes a
ruling: `ruled`, `action: "ruled"` or any other claim the lane writes on its own
row is ignored. Step 16's `finish` reads the branch's rulings
(`lib/ruled-notes.ts`) and prints each as `RULED_NOTE: <fingerprint> at <head12>
ruled by <who> at <time>: <reason> (approval <id>)`. A ruling closes a finding on
the newest `review-fix-check` row (action `skipped`, `noted` or `deferred`, not a
gate finding) when its head lies inside that row's `range_base..commit`, the same
test a `review_loop_grant` passes. So the order of ruling and fix-check doesn't
matter, and a ruling made at an earlier /ship's head closes nothing: its
`RULED_NOTE:` line ends `(outside this fix-check's range; not applied)`. A granted
fix-check whose other findings are all below the floor then reads CURRENT and
counts. A skipped CRITICAL with no ruling stays open. Nothing ruled is hidden:
`gstack-approvals rulings` prints one `Ruled note: …` line per ruling for the
Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
line word for word, and the READY block prints each as `RULED_NOTE:`.
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
+3
View File
@@ -123,6 +123,8 @@ export interface ShipReceipt {
review_depth?: { depth: 'platform' | 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[]; risk?: string[]; mode?: 'lean' | 'panel' | 'platform'; platform_review?: { id: string; head: string; reviewer: string }; declared?: string[]; detected?: string[] };
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check?: { head: string; findings: number; files?: string[] }; second_pass?: { head: string; deferred: string[] } };
/** The owner's rulings that a finding ships as a disclosed note (gstack-approvals rule, lib/ruled-notes.ts); the PR body lists each one word for word (GST-1537). */
ruled_notes?: Array<{ finding: string; head: string; by: string; reason: string; at: string; approval: string }>;
/** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */
review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string };
/** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */
@@ -267,6 +269,7 @@ export const ARTIFACT_SCHEMAS = {
review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } }, second_pass: { type: 'object', required: ['head', 'deferred'], properties: { head: str, deferred: strList } } } },
ruled_notes: { type: 'array', items: { type: 'object', required: ['finding', 'head', 'by', 'reason', 'at', 'approval'], properties: { finding: str, head: str, by: str, reason: str, at: str, approval: str } } },
no_stop: { type: 'array', items: { type: 'object', required: ['class', 'reason', 'evidence'], properties: { class: { enum: [...NO_STOP_CLASSES] }, reason: str, evidence: str } } },
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' } } } } } },
},
+22 -4
View File
@@ -335,7 +335,26 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
return rec;
}
export function reviewFreshness(rec: Record<string, any>, currentWtree: string): { status: string; reason: string } | undefined {
const unresolvedFinding = (f: unknown) => record(f) && (f.action === 'open' ||
((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) ||
(!f.gate && isCriticalFinding(f) && isOpenByAction(f)));
const RULABLE = new Set(['skipped', 'noted', 'deferred']);
/**
* GST-1537: the claimed row with each finding the owner ruled a disclosed note (`ruled`: fingerprints from approved
* `gstack-approvals rule` rows, lib/ruled-notes.ts, never from the row itself) taken out, and recorded clean when the
* rest leave nothing unresolved and the findings account for every claimed issue and critical; otherwise unchanged.
*/
function withoutRuledNotes(rec: Record<string, any>, ruled: ReadonlySet<string>): Record<string, any> {
const isRuled = (f: unknown) => record(f) && !f.gate && RULABLE.has(f.action) && typeof f.fingerprint === 'string' && ruled.has(f.fingerprint);
if (!ruled.size || rec.status_source === 'derived' || rec.completed !== true || !Array.isArray(rec.findings) ||
!rec.findings.some(isRuled) || rec.findings.length < Number(rec.issues_found ?? 0) ||
rec.findings.filter(f => record(f) && isCriticalFinding(f)).length < Number(rec.critical ?? 0) ||
rec.findings.some(f => !isRuled(f) && (!record(f) || unresolvedFinding(f)))) return rec;
return { ...rec, status: 'clean', issues_found: 0, critical: 0, informational: 0, findings: rec.findings.filter(f => !isRuled(f)) };
}
export function reviewFreshness(rec: Record<string, any>, currentWtree: string, ruled: ReadonlySet<string> = new Set()): { status: string; reason: string } | undefined {
if (!DIFF_REVIEWS.has(rec.skill)) return;
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
@@ -354,12 +373,11 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
// B7: a derived row carries findings_open; a resolved critical finding no
// longer counts against it. Claimed rows keep the conservative reading of
// issues_found, and the legacy `critical` and Codex counters stay as they were.
rec = withoutRuledNotes(rec, ruled);
const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found;
if (rec.status !== 'clean' || open > 0 || rec.critical > 0 ||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0)) ||
(Array.isArray(rec.findings) && rec.findings.some(f => record(f) && (f.action === 'open' ||
((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) ||
(!f.gate && isCriticalFinding(f) && isOpenByAction(f)))))) {
(Array.isArray(rec.findings) && rec.findings.some(unresolvedFinding))) {
return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' };
}
return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' };
+50
View File
@@ -0,0 +1,50 @@
/**
* Owner-ruled notes (GST-1537, docs/ship-policy.md#ruled-notes): the owner's ruling that a review finding ships as a
* disclosed note instead of a fix. A ruling is one approved gstack-approvals row (`gstack-approvals rule`), with scope
* `ruled_note <finding fingerprint> <head>` and ask `ruled_note <finding fingerprint> at <head>: <reason>`, where head is the
* full sha of the stop head the owner ruled at; the row's `by` is the ruler and its `recorded_at` the time. A review-log field
* never makes one, so the lane that logged a finding cannot close it.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { locate, readRecord, type ApprovalRow, type Env } from './approval-record';
export interface RuledNote { finding: string; head: string; by: string; reason: string; at: string; approval: string }
const SCOPE = /^ruled_note (\S+) ([0-9a-f]{40})$/;
const ASK = /^ruled_note (\S+) at ([0-9a-f]{40}): (\S(?:.*\S)?)$/;
/** A ruler gstack names for itself (the no-stop default's `gstack no-stop default (K1)`) is no owner. */
export const GSTACK_ACTOR = /^gstack\b/i;
export const RULED_NOTE_SCOPE = /^\s*ruled_note\b/;
export const ruledNoteRow = (finding: string, head: string, reason: string) => ({ scope: `ruled_note ${finding} ${head}`, ask: `ruled_note ${finding} at ${head}: ${reason}` });
/** The approved ruling rows, one per finding and head (the newest wins). */
export function ruledNotes(rows: readonly ApprovalRow[]): RuledNote[] {
const byFinding = new Map<string, RuledNote>();
for (const r of rows) {
const scope = SCOPE.exec(r.scope), ask = ASK.exec(r.ask);
if (r.outcome !== 'approved' || !scope || !ask || ask[1] !== scope[1] || ask[2] !== scope[2] || GSTACK_ACTOR.test(r.by)) continue;
const finding = scope[1]!, head = scope[2]!, key = `${finding} ${head}`;
byFinding.delete(key);
byFinding.set(key, { finding, head, by: r.by, reason: ask[3]!, at: r.recorded_at, approval: r.id });
}
return [...byFinding.values()];
}
/** The rulings in the checked-out branch's approval record (and its mirror). */
export function readRuledNotes(cwd: string, env: Env = process.env): RuledNote[] {
const loc = locate({ cwd, env });
return 'code' in loc ? [] : ruledNotes(readRecord(loc).rows);
}
/** The one-line form finish prints after `RULED_NOTE: ` and the PR body carries after `Ruled note: `. */
export const ruledNoteText = (n: RuledNote) => `${n.finding} at ${n.head.slice(0, 12)} ruled by ${n.by} at ${n.at}: ${n.reason} (approval ${n.approval})`;
/** The /ship run open on this checkout (its marker in the git dir, which finish removes), if any. */
export function openShipRun(cwd: string): string | undefined {
const p = spawnSync('git', ['rev-parse', '--git-path', 'gstack-ship-run'], { cwd, encoding: 'utf8', timeout: 10_000 });
if (p.status !== 0 || !p.stdout.trim()) return undefined;
try { return fs.readFileSync(path.resolve(cwd, p.stdout.trim()), 'utf8').trim() || undefined; } catch { return undefined; }
}
+3 -1
View File
@@ -9,6 +9,7 @@ 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';
export interface ProseClaim { field: string; prose: number; receipt: number }
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
@@ -95,7 +96,7 @@ function withoutApprovalsBlocks(body: string): string {
/**
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* (GST-977) and the `Documentation exception: …` line (GST-983). write and the receipt check want each word for word,
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …` line (GST-1537). 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.
@@ -107,6 +108,7 @@ function withoutExceptionLines(body: string, receipt: Record<string, unknown>):
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 (!lines.size) return body;
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
}
+3 -1
View File
@@ -12,6 +12,7 @@ import { approvalSummary } from './approval-citations';
import { FRAGMENT_README } from './changelog-fragments';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { ruledNoteText } from './ruled-notes';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine, type BranchSide } from './stamp-only';
@@ -20,7 +21,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red', 'flaky_exception', 'no_stop',
'review_loop_exception', 'ruled_notes', 'review_targeted', 'review_exclude', 'carry', 'base_red', 'flaky_exception', 'no_stop',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -173,6 +174,7 @@ export function readyTailLines(r: ShipReceipt | undefined): string[] {
const legacy = !!r && !r.review_depth;
return [
loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt',
...(r?.ruled_notes ?? []).map(n => `RULED_NOTE: ${oneLine(ruledNoteText(n))}`),
'TRIAL_MERGES: <one line per open sibling pin that shares a file: its git merge-tree result and the resolution, or "none share a file">',
'Attention:',
legacy ? 'reviewed by <model on another family> (risk class unknown: the receipt records no review_depth; rule 27 cross-family trail read)'
+9 -4
View File
@@ -19,6 +19,7 @@ import { baseHolds, boundedPaths, comparePath, contextPacket, gateEditedPaths, i
import { heldBy } from './ship-receipt';
import { locate, readRecord } from './approval-record';
import { reviewFreshness } from './review-evidence';
import { readRuledNotes, ruledNoteText } from './ruled-notes';
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
@@ -197,8 +198,9 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
* Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts).
* A code or test path the newest completed clean review-fix-check row read (inside its range_base..commit range, where
* range_base holds the reviewed tree, and unchanged since) counts as fix_checked when an approved review_loop_grant names a stop
* head inside range_base..commit: the owner's granted fix-check after Step 11 is a review (GST-1130). Authored docs keep the
* stage 2.2 recheck.
* head inside range_base..commit: the owner's granted fix-check after Step 11 is a review (GST-1130). A finding on that row the
* owner ruled a disclosed note (an approved gstack-approvals rule row whose stop head lies inside the row's range_base..commit, never the row's own claim) is closed, and every ruling
* prints as RULED_NOTE (GST-1537). Authored docs keep the stage 2.2 recheck.
*/
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'bounded' | 'accepted' | 'fix_checked', string[]>;
@@ -281,11 +283,14 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: Review
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : '';
const onBranch = (c: string) => !!c && git(cwd, ['merge-base', '--is-ancestor', c, 'HEAD']).status === 0;
const fixCommit = commitOf(fixRow?.commit_full), fixFrom = commitOf(fixRow?.range_base);
const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree);
const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
const inFixRange = (c: string) => !!fixFrom && !!fixCommit && ancestor(fixFrom, c) && ancestor(c, fixCommit);
const rulings = readRuledNotes(cwd).map(n => ({ ...n, applied: !!fixRow && inFixRange(n.head) }));
for (const n of rulings) r.add(`RULED_NOTE: ${ruledNoteText(n)}${fixRow && !n.applied ? ' (outside this fix-check\'s range; not applied)' : ''}`);
const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree, new Set(rulings.filter(n => n.applied).map(n => n.finding)));
const grants = fixRow ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })) : [];
const grant = grants.find(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant')
&& (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some(s => { const c = commitOf(s); return !!c && !!fixFrom && !!fixCommit && ancestor(fixFrom, c) && ancestor(c, fixCommit); }));
&& (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some(s => { const c = commitOf(s); return !!c && inFixRange(c); }));
const notCounted = !fixRow ? '' : fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row'
: fixRow.tree !== fixRow.wtree || !onBranch(fixCommit) ? 'the row was logged on content no branch commit holds'
: !onBranch(fixFrom) ? 'the row names no range_base commit on this branch'
+4
View File
@@ -403,6 +403,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/ship-finish-stack-parent.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 41950236362)',
},
{
file: 'test/ship-finish-ruled-notes.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-approvals, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42139467391)',
},
];
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
+5
View File
@@ -102,6 +102,8 @@ ZERO-RUN lane never reaches this body unresolved.>
## Pre-Landing Review
<findings from Step 9 code review, or "No issues found."; then Step 9.4's `REVIEW_LOOP:` line.>
<Each `Ruled note: …` line `gstack-approvals rulings` prints, verbatim: a finding the owner ruled a disclosed note,
with its ruler and reason. `gstack-ship-receipt write --body` refuses a body missing one.>
<Gate edits: Step 9's latest `Gate edits:` line verbatim — `none detected (...)`,
`N listed, M read, K findings — J kept, R restored, O open`, `partial (...)` or
`UNAVAILABLE — ...`; never `none detected` for partial or UNAVAILABLE. Under it, each
@@ -232,6 +234,9 @@ or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing writt
When the owner accepted a stop after a fix-check edit, add
`--review-loop-exception @<file>` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`)
and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`.
A finding the owner rules a disclosed note instead of a fix is closed only by the owner's
`gstack-approvals rule`, recorded outside this run, never by this run or a review-log field
(docs/ship-policy.md#ruled-notes); the receipt lists each one as `ruled_notes`.
When `gstack-review-dispatch verify` printed `REVIEW_EXCLUDE_RECORD: digest=<d>`, add `--review-exclude <d>`
and Step 14.5's `--docs-post <audit-id>-post.json` and `--docs-pre <its Prepare snapshot>`; a digest that no longer matches HEAD is
`RECEIPT_INVALID field=review_exclude`: rerun the review, not the write, unless only the docs audit's
+5
View File
@@ -93,6 +93,8 @@ ZERO-RUN lane never reaches this body unresolved.>
## Pre-Landing Review
<findings from Step 9 code review, or "No issues found."; then Step 9.4's `REVIEW_LOOP:` line.>
<Each `Ruled note: …` line `gstack-approvals rulings` prints, verbatim: a finding the owner ruled a disclosed note,
with its ruler and reason. `gstack-ship-receipt write --body` refuses a body missing one.>
<Gate edits: Step 9's latest `Gate edits:` line verbatim — `none detected (...)`,
`N listed, M read, K findings — J kept, R restored, O open`, `partial (...)` or
`UNAVAILABLE — ...`; never `none detected` for partial or UNAVAILABLE. Under it, each
@@ -215,6 +217,9 @@ or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing writt
When the owner accepted a stop after a fix-check edit, add
`--review-loop-exception @<file>` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`)
and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`.
A finding the owner rules a disclosed note instead of a fix is closed only by the owner's
`gstack-approvals rule`, recorded outside this run, never by this run or a review-log field
(docs/ship-policy.md#ruled-notes); the receipt lists each one as `ruled_notes`.
When `gstack-review-dispatch verify` printed `REVIEW_EXCLUDE_RECORD: digest=<d>`, add `--review-exclude <d>`
and Step 14.5's `--docs-post <audit-id>-post.json` and `--docs-pre <its Prepare snapshot>`; a digest that no longer matches HEAD is
`RECEIPT_INVALID field=review_exclude`: rerun the review, not the write, unless only the docs audit's
+195
View File
@@ -0,0 +1,195 @@
/**
* Owner-ruled notes (GST-1537, CAPY-1 qm#3122 at 532cc5c): a CRITICAL the owner rules a disclosed note is closed for
* finish once the ruling is recorded through gstack-approvals, and only then. The lane's own review-log row can say
* whatever it likes about a ruling; finish reads rulings from the approval record alone, and the receipt and PR body list
* every one with its ruler and reason.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { buildRow } from '../lib/approval-record';
import { ruledNotes, ruledNoteText } from '../lib/ruled-notes';
import { extractReceipt } from '../lib/ship-receipt';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(90_000);
const BIN = path.dirname(RELEASE);
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
const ships: ShipFx[] = [];
afterEach(() => { while (ships.length) ships.pop()!.cleanup(); });
const RACE = 'src/x.ts:1:edit-share-race';
const AMBIENT = 'src/x.ts:1:ambient-turns';
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n";
const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 1_000);\n";
/** The qm#3122 fix-check row: two outside-voice CRITICALs the owner ruled notes (logged skipped) and the fix-check's own two below-floor notes. */
const QM3122 = {
completed: true, converged: true, status: 'issues_found', issues_found: 4, critical: 2, informational: 2,
findings: [
{ fingerprint: RACE, severity: 'CRITICAL', action: 'skipped', raised_by: 'outside-voice', blocking: true },
{ fingerprint: AMBIENT, severity: 'CRITICAL', action: 'skipped', raised_by: 'outside-voice', blocking: true },
{ fingerprint: 'src/x.ts:1:naming', severity: 'INFORMATIONAL', action: 'noted', raised_by: 'fix-check', blocking: false },
{ fingerprint: 'test/x.test.ts:2:coverage', severity: 'INFORMATIONAL', action: 'noted', raised_by: 'fix-check', blocking: false },
],
};
const NOT_CLEAN = 'FIX_CHECK: not counted (review has unresolved findings or did not finish clean)';
const run = (f: ShipFx, bin: string, args: string[]) => f.run(path.join(BIN, bin), args, { GSTACK_APPROVALS_MIRROR: '0' });
const finish = (f: ShipFx, reviewed: string, post: string) => f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
const rule = (f: ShipFx, finding: string, head: string, by = 'qm minder (CAPY-1)') => run(f, 'gstack-approvals', ['rule', '--finding', finding, '--head', head, '--reason', `${finding.split(':')[2]} ships as a disclosed note`, '--words', 'both are notes, not fixes', '--by', by, '--json']);
const logFixCheck = (f: ShipFx, row: Record<string, unknown>) => {
const start = run(f, 'gstack-review-log', ['--start', 'review-fix-check']);
expect(start.status).toBe(0);
const logged = run(f, 'gstack-review-log', [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-05T00:00:00Z', via: 'ship', review_scope: 'frozen', ...row }), '--finish', start.stdout.trim()]);
expect(logged.status, logged.stderr).toBe(0);
};
/** Round 2 stopped at passHead, the owner granted one scoped fix-check, the fix landed, and the fix-check read it. */
function grantedFixCheck(row: Record<string, unknown>, beforeLog?: (f: ShipFx, passHead: string) => void) {
const f = shipFixture({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 });
ships.push(f);
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker');
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
const post = path.join(f.root, 'post.json');
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
const grant = run(f, 'gstack-approvals', ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${passHead.slice(0, 12)})`, '--words', 'one scoped fix-check', '--by', 'qm minder (CAPY-1)', '--scope', 'this /ship invocation', '--json']);
expect(grant.status).toBe(0);
beforeLog?.(f, passHead);
logFixCheck(f, { range_base: passHead, ...row });
return { f, passHead, fixHead, reviewed, post, grant: JSON.parse(grant.stdout).added[0] as string };
}
// Value: protects=the qm#3122 shape binds once the owner's two rulings are recorded: a granted fix-check whose own findings
// are all below the floor reads CURRENT and finish counts it, printing every ruling; fails_when=finish still counts a
// skipped owner-ruled CRITICAL as open (FIX_CHECK: not counted) or binds without naming the rulings; seam=finish's route
// reading the review log and the approval record; no_claim=Step 11.5's full-pass binding
describe('finish with owner-ruled notes (GST-1537)', () => {
test('qm#3122: two ruled CRITICALs plus a clean fix-check, and finish binds', () => {
const { f, passHead, fixHead, reviewed, post, grant } = grantedFixCheck(QM3122);
const before = finish(f, reviewed, post);
expect([before.status, lineOf(before.stdout, 'FIX_CHECK')]).toEqual([3, NOT_CLEAN]);
for (const finding of [RACE, AMBIENT]) expect(rule(f, finding, passHead).status).toBe(0);
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}, grant ${grant})`);
const ruled = r.stdout.split('\n').filter(l => l.startsWith('RULED_NOTE: '));
expect(ruled).toEqual([
expect.stringMatching(new RegExp(`^RULED_NOTE: ${RACE} at ${passHead.slice(0, 12)} ruled by qm minder \\(CAPY-1\\) at \\d{4}-\\d\\d-\\d\\dT[^ ]+Z: edit-share-race ships as a disclosed note \\(approval a-[0-9a-f]{12}\\)$`)),
expect.stringMatching(new RegExp(`^RULED_NOTE: ${AMBIENT} at ${passHead.slice(0, 12)} ruled by qm minder \\(CAPY-1\\) at .*\\)$`)),
]);
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
});
test('the forward order binds too: the owner rules at the stop head, then the granted fix-check runs', () => {
const { f, fixHead, reviewed, post } = grantedFixCheck(QM3122, (fx, head) => { for (const finding of [RACE, AMBIENT]) expect(rule(fx, finding, head).status).toBe(0); });
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toStartWith(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}`);
expect(r.stdout).not.toContain('not applied');
});
test('an unruled skipped CRITICAL stays open: one ruling of two leaves the fix-check uncounted', () => {
const { f, passHead, reviewed, post } = grantedFixCheck(QM3122);
expect(rule(f, RACE, passHead).status).toBe(0);
const r = finish(f, reviewed, post);
expect([r.status, lineOf(r.stdout, 'FIX_CHECK')]).toEqual([3, NOT_CLEAN]);
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)');
expect(r.stdout.split('\n').filter(l => l.startsWith('RULED_NOTE: ')).length).toBe(1);
});
test('a forged ruling is refused: the lane\'s own row, an add with the ruling scope, a ruling inside the open /ship run, and a ruling gstack wrote', () => {
const forged = { ...QM3122, findings: QM3122.findings.map(x => x.severity === 'CRITICAL' ? { ...x, action: 'skipped', ruled: 'note', ruled_by: 'qm minder (CAPY-1)', ruled_reason: 'owner said so', ruled_at: '2026-11-05T00:00:00Z' } : x) };
const { f, passHead, reviewed, post } = grantedFixCheck(forged);
expect(lineOf(finish(f, reviewed, post).stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
logFixCheck(f, { range_base: f.git('rev-parse', 'HEAD~1').stdout.trim(), ...QM3122, findings: QM3122.findings.map(x => x.severity === 'CRITICAL' ? { ...x, action: 'ruled' } : x) });
expect(lineOf(finish(f, reviewed, post).stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
const add = run(f, 'gstack-approvals', ['add', '--ask', 'ruled_note: owner said so', '--words', 'notes', '--by', 'qm minder (CAPY-1)', '--scope', `ruled_note ${RACE}`]);
expect([add.status, add.stderr]).toEqual([EXIT.usage, expect.stringContaining('gstack-approvals rule')]);
const marker = f.git('rev-parse', '--git-path', 'gstack-ship-run').stdout.trim();
fs.writeFileSync(path.resolve(f.work, marker), 'ship-3122-a022992\n');
const inRun = rule(f, RACE, passHead);
expect([inRun.status, inRun.stderr]).toEqual([EXIT.usage, expect.stringContaining('/ship run ship-3122-a022992 is open on this checkout')]);
fs.rmSync(path.resolve(f.work, marker));
const self = rule(f, RACE, passHead, 'gstack no-stop default (K1)');
expect([self.status, self.stderr]).toEqual([EXIT.usage, expect.stringContaining('--by')]);
expect(lineOf(finish(f, reviewed, post).stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
});
// Value: protects=a ruling closes only findings on a fix-check whose range holds the head the owner ruled at; a ruling made at
// an earlier /ship's head stays listed but closes nothing; fails_when=a ruling closes a later finding at the same fingerprint;
// seam=finish's route matching each ruling's head to the fix-check row's range_base..commit
test('a ruling at an earlier head leaves the CRITICAL open and is listed as not applied', () => {
const { f, reviewed, post } = grantedFixCheck(QM3122);
const mergeBase = f.git('merge-base', 'main', 'HEAD').stdout.trim();
for (const finding of [RACE, AMBIENT]) expect(rule(f, finding, mergeBase).status).toBe(0);
const r = finish(f, reviewed, post);
expect([r.status, lineOf(r.stdout, 'FIX_CHECK')]).toEqual([3, NOT_CLEAN]);
expect(r.stdout.split('\n').filter(l => l.startsWith('RULED_NOTE: ')).map(l => l.endsWith(" (outside this fix-check's range; not applied)"))).toEqual([true, true]);
});
test('rule refuses a missing --head, a head that is not a commit and a multi-line reason', () => {
const { f, passHead } = grantedFixCheck(QM3122);
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
for (const args of [['--finding', RACE], ['--finding', RACE, '--head', '0123456789abcdef'], ['--finding', RACE, '--head', tree]]) {
const r = run(f, 'gstack-approvals', ['rule', ...args, '--reason', 'a note', '--words', 'notes', '--by', 'qm minder (CAPY-1)']);
expect([r.status, r.stderr]).toEqual([EXIT.usage, expect.stringContaining('--head')]);
}
const lines = run(f, 'gstack-approvals', ['rule', '--finding', RACE, '--head', passHead, '--reason', 'first line\nsecond line', '--words', 'notes', '--by', 'qm minder (CAPY-1)']);
expect([lines.status, lines.stderr]).toEqual([EXIT.usage, expect.stringContaining('one line')]);
expect(run(f, 'gstack-approvals', ['rulings']).stdout).toBe('RULED_NOTES: none\n');
});
// Value: protects=one owner reply ruling two findings with the same reason records both; fails_when=the second ruling
// hashes to the first row's id and is skipped as already present, leaving its finding open; seam=gstack-approvals rule
test('two rulings with the same reason, words and ruler both record', () => {
const { f, passHead } = grantedFixCheck(QM3122);
const same = (finding: string) => run(f, 'gstack-approvals', ['rule', '--finding', finding, '--head', passHead, '--reason', 'both ship as disclosed notes', '--words', 'both are notes, not fixes', '--by', 'qm minder (CAPY-1)', '--json']);
const [a, b] = [same(RACE), same(AMBIENT)];
expect([a.status, b.status, JSON.parse(b.stdout).skipped]).toEqual([0, 0, 0]);
expect(run(f, 'gstack-approvals', ['rulings']).stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^Ruled note: ${RACE} at `)), expect.stringMatching(new RegExp(`^Ruled note: ${AMBIENT} at `))]);
});
// Value: protects=nothing ruled is hidden (R73): the receipt records every ruling and write refuses a PR body that does not
// list each one word for word; fails_when=write drops the rulings or accepts a body without them; seam=gstack-ship-receipt write
test('the receipt records every ruling, and write refuses a body that does not list each one', () => {
const { f, passHead } = grantedFixCheck(QM3122);
for (const finding of [RACE, AMBIENT]) expect(rule(f, finding, passHead).status).toBe(0);
const listed = run(f, 'gstack-approvals', ['rulings']);
expect(listed.status).toBe(0);
const lines = listed.stdout.trim().split('\n');
expect(lines).toEqual([expect.stringMatching(new RegExp(`^Ruled note: ${RACE} at ${passHead.slice(0, 12)} ruled by `)), expect.stringMatching(new RegExp(`^Ruled note: ${AMBIENT} at ${passHead.slice(0, 12)} ruled by `))]);
const body = path.join(f.root, 'body.md');
const write = () => f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], { GSTACK_APPROVALS_MIRROR: '0' });
fs.writeFileSync(body, `## Pre-Landing Review\n\n${lines[0]}\n`);
const short = write();
expect([short.status, short.stderr]).toEqual([EXIT.fail, expect.stringContaining(`does not list ruled note ${JSON.stringify(lines[1])}`)]);
fs.writeFileSync(body, `## Pre-Landing Review\n\n${lines.join('\n')}\n`);
const ok = write();
expect(ok.status, ok.stderr).toBe(EXIT.ok);
const receipt = extractReceipt(ok.stdout);
expect(receipt.errors).toEqual([]);
expect(receipt.receipt?.ruled_notes?.map(n => n.finding)).toEqual([RACE, AMBIENT]);
expect(receipt.receipt?.ruled_notes?.[0]).toMatchObject({ head: passHead, by: 'qm minder (CAPY-1)', reason: 'edit-share-race ships as a disclosed note', approval: expect.stringMatching(/^a-/), at: expect.stringMatching(/Z$/) });
});
});
describe('ruledNotes reads only approved owner rulings (GST-1537)', () => {
const HEAD = '0123456789abcdef0123456789abcdef01234567';
const row = (finding: string, o: Partial<{ ask: string; scope: string; by: string; outcome: string }> = {}) => {
const r = buildRow({ ask: `ruled_note ${finding} at ${HEAD}: the lock already allows it`, words: 'a note', by: 'qm minder (CAPY-1)', scope: `ruled_note ${finding} ${HEAD}`, ...o }, 'feat', new Date('2026-11-05T01:02:03Z'));
if ('code' in r) throw new Error(r.message);
return r;
};
test('an approved ruling row counts; pending, rejected, mismatched, headless and gstack-written rows do not', () => {
for (const excluded of [row(AMBIENT, { outcome: 'pending' }), row(AMBIENT, { outcome: 'rejected' }), row(AMBIENT, { ask: 'the lock already allows it' }),
row(AMBIENT, { ask: `ruled_note ${RACE} at ${HEAD}: the lock already allows it` }), row(AMBIENT, { scope: `ruled_note ${AMBIENT}` }), row(AMBIENT, { by: 'gstack no-stop default (K1)' })]) {
expect(ruledNotes([excluded])).toEqual([]);
}
const notes = ruledNotes([row(RACE)]);
expect(notes).toEqual([{ finding: RACE, head: HEAD, by: 'qm minder (CAPY-1)', reason: 'the lock already allows it', at: '2026-11-05T01:02:03.000Z', approval: expect.stringMatching(/^a-[0-9a-f]{12}$/) }]);
expect(ruledNoteText(notes[0]!)).toBe(`${RACE} at 0123456789ab ruled by qm minder (CAPY-1) at 2026-11-05T01:02:03.000Z: the lock already allows it (approval ${notes[0]!.approval})`);
});
});
@@ -0,0 +1,4 @@
{
"bytes": 471,
"why": "GST-1537: the PR body's Pre-Landing Review lists each owner-ruled note and the receipt step names gstack-approvals rule as the only path"
}