fix(ship): keep review credit across base merges and docs commits; grant after a fix-check; second-pass loop exception (GST-1195) (#3970)

* fix(ship): a fix-checked path keeps its credit across a base merge that edits it; the docs audit carries a path whose branch patch is unchanged (GST-1195)

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

* fix(review): a recorded grant gets its one fix-check after a round-2 fix-check (GEVA-223, GST-1130)

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

* fix(ship): a targeted record carries across a plain docs commit; review_loop_exception.second_pass for a stop after the second full pass (GST-1195, TRUT-26, TRUT-7)

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

* test: type the docs-commit cases (typecheck:test ratchet)

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

* fix(ship): the docs-audit carry compares the checked tree, not HEAD; repin lib/review-depth.ts (replay GEVA-223)

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

* fix(ship): a binary or auth/security-worded docs commit does not carry a targeted record (review_loop_exception, #3970 round 2)

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

* docs: describe the GST-1195 bundle's shipped behavior (ship docs audit gst1195-da1)

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-04 04:35:24 +00:00
1 parent e5366a52b7
commit a66d2f460d
18 files changed
+288 -63

No files matched your search

+2 -1
View File
@@ -70,7 +70,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
its ask or scope naming review_loop_grant and the stop head's sha, at or after --range-base and at or
before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a
gate-class edit plans that fix-check instead; a fix-check never earns another; any other row is refused (exit 3)
--fix-checks <n> fix-checks already run in this invocation (R65's lean loop: the owner's grant adds one only after the first)
--fix-checks <n> fix-checks already run in this invocation: the owner's grant adds one only after the first, in the lean
loop and after a round-2 fix-check in the full loop (GEVA-223)
--pr <n> the PR whose platform review counts under review.platform on (default: this branch's PR)
--declare-risk <classes> risk classes the lane declares for the diff (comma list of ${RISK_CLASSES.join(', ')}): they join
the detected classes and only raise the depth; RISK_DECLARED: prints both (GST-1129)
+8 -6
View File
@@ -51,7 +51,7 @@ import {
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
import { planRecheck, readRecheck, recheckPacket } from '../lib/ship-recheck';
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, platformReceiptDepth, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { deeper, depthBelow, exceptionHead, exceptionLoopProblem, exceptionProblems, parseReviewLoopException, parseReviewTargeted, plainDocsSince, platformReceiptDepth, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
@@ -98,7 +98,8 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim)
--review-loop-exception <json|@file> ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a
stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the
"Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
"Review-loop exception: …" line verbatim; or second_pass: {head, deferred: [<finding id>]} in place of fix_check for a stop after
the second full pass with a finding left open, needing passes >= 2 and deferred >= 1; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
unreadable input records full).
--review-exclude <digest|none> (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
@@ -244,9 +245,10 @@ function cmdWrite(rest: string[], json: boolean): number {
try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); }
if (!loopException.ok) return refuse(loopException.message);
let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {}
if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check');
const fc = loopException.exception.fix_check.head;
if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`);
const loopProblem = exceptionLoopProblem(loopException.exception, parseReviewLoop(loop));
if (loopProblem) return refuse(`--review-loop-exception ${loopProblem}`);
const fc = exceptionHead(loopException.exception);
if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`${loopException.exception.fix_check ? 'fix_check' : 'second_pass'}.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`);
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)}`);
}
@@ -270,7 +272,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const product = reviewedProduct(repoRoot, base, head, pregateFile);
let targeted: ReturnType<typeof parseReviewTargeted> | undefined;
if (flag(rest, '--review-targeted') !== undefined) {
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head), read => plainDocsSince(repoRoot, read, headSha ?? head)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
}
if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; }
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
+9
View File
@@ -0,0 +1,9 @@
**/ship loses less review credit to merges and docs commits, and an owner can accept an open finding after the second full pass (GST-1195, GST-1130).**
### Fixed
- **A granted fix-check keeps its credit across a base merge that edits the same file.** `gstack-ship-release finish` now credits a fix-checked path when its change against the base is the one the fix-check read, even if the base moved under it. Before, it compared file contents, so a merge that also touched the file printed `FIX_CHECK: reviewed none` and sent the path back through review (#3916). The docs audit carries such a path too. An edit made after the merge still stops.
- **A recorded grant gets its fix-check when round 2 was itself a fix-check** (GEVA-223). `gstack-review-plan --grant <id> --fix-checks 1` now plans the one granted fix-check after a round-2 fix-check that found or fixed a blocker, instead of `NEXT: stop`. A second granted fix-check is still refused.
- **A targeted reader's record carries across a plain docs commit made after the read** (TRUT-26, truther#1168). `gstack-ship-receipt write --review-targeted` refused the record on any product change, so the Step 14 and 14.5 docs commits cost another reader run. A docs-only change now carries the record, unless it adds a numeric claim, touches a results, prereg or eval path, or mentions money, security, secrets or auth. Code changes still need the reader again.
### Added
- **`review_loop_exception.second_pass`** (TRUT-7, truther#1169). An owner who accepts an open finding after the second full pass records `{ accepted_by, date, via, reason, second_pass: { head, deferred: [<finding id>] } }`. It needs `--review-loop` with at least two passes and one deferred finding, plus the printed `Review-loop exception: …` line in the body. A stop with no fix-check and fewer than two full passes is still refused.
+20 -12
View File
@@ -106,11 +106,12 @@ incomplete coverage, which blocks until the reviewer is rerun.
loop's: once `gstack-approvals add` records the grant (its ask or scope
naming `review_loop_grant` and the stop head's sha),
`gstack-review-plan --grant <approval id> --range-base <round 2's PASS_HEAD>`
plans that fix-check in place of the stop after a round-2 full pass with a
fixed blocking finding or a gate-class edit, and refuses any other row,
plans that fix-check in place of the stop after a round-2 full pass, or after
a round-2 fix-check that was the run's only one (`--fix-checks 1`, GEVA-223),
with a fixed blocking finding or a gate-class edit, and refuses any other row,
including a grant for a stop head before `--range-base` (an earlier /ship).
A fix-check never earns another, so the grant cannot repeat, and a stop after
a round-2 fix-check still goes to the owner. The cap approves nothing.
the granted fix-check still goes to the owner. The cap approves nothing.
<a id="transitions"></a>
### Transitions
@@ -134,6 +135,7 @@ incomplete coverage, which blocks until the reviewer is rerun.
| a fix-check that found a blocking finding | stop: name it for the owner |
| a stop (any row above) on gate class before Step 11's voices ran | Step 11's voices (adversarial, outside, targeted), then ask again with `--adversarial-ran` |
| a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner's recorded grant (`--grant <approval id>`) | one scoped fix-check (round 3), after Step 11 on gate class |
| a round-2 fix-check that stops the same way, with the owner's recorded grant and `--fix-checks 1` | one scoped fix-check (round 3), after Step 11 on gate class |
A **fix-check** is one reviewer reading the touched hunks, the fix list, the
test results and the callers of every changed symbol, at full effort. It is
@@ -241,8 +243,9 @@ lane knows the diff has, `gstack-review-plan --declare-risk <classes>` adds it:
detected classes, only raises the depth, and prints `RISK_DECLARED: classes=<declared> detected=<found>`.
The outside voice reads its diff from a file the brief names with its sha256; past
2,000,000 bytes it reads a path-scoped slice whose brief lists the files left out.
`--fix-checks <n>` tells the lean loop how many fix-checks ran, so the owner's grant
adds one fix-check after the first and never a second.
`--fix-checks <n>` tells the loop how many fix-checks ran, so the owner's grant
adds one fix-check after the first and never a second, in the lean loop and after
a round-2 fix-check in the full loop (GEVA-223).
**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs**
(markdown no agent or gate reads), **tests** (test files and fixtures), **code**
@@ -483,9 +486,11 @@ renders its brief; `verify --targeted-out .gstack/tmp/targeted.json` writes the
the reader got its brief verbatim and its reply was collected on a committed diff, whatever
happened to HEAD after, and it names the commit it read (`head`). `write` binds it only while
that commit is an ancestor of HEAD, its product patch is the receipt's, and nothing changed
after it carries a targeted class, with the gate scan run over that range: any product change
after the read refuses with `RECEIPT_INVALID`, docs and TODOS.md commits included, and so
does a test edit the gate scan tags (a skipped case). Every verify
after it carries a targeted class, with the gate scan run over that range: any other product change
after the read refuses with `RECEIPT_INVALID`, and so does a test edit the gate scan tags (a
skipped case). A docs-only change after the read (TODOS.md included) carries the record only
when it changes no binary file, adds no numeric claim, touches no results, prereg or eval path,
and names no money, security, secret or auth topic (GST-1195). Every verify
removes `--targeted-out` before it writes a record, so an earlier run's record never reaches
Step 19. `write` and `carry` (which rescans the gate at the new head) also
refuse a receipt whose head needs a class no `done` record covers (`RECEIPT_INVALID`,
@@ -544,7 +549,7 @@ Findings bind by id first (GST-1168). A row finding that came from a dispatched
| `review_loop.rounds` | passes plus fix-checks (R51 caps it at 2; the queue's `REVIEW_DEPTH:` line prints it) |
| `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`, `targeted` |
| `review_loop.est_input_tokens` | prompt plus diff bytes the dispatches read, divided by 4: a relative estimate for comparing runs, not a bill |
| `review_targeted` | the [targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code): the `classes` it covered, `rules`, `brief_sha`, the receipt's own `product_patch_id`, `status` (`done` only with its brief sent verbatim and its output collected on a committed diff, else `failed`; `write` refuses a record when a change after its read commit carries a targeted class or the read commit is not an ancestor, and refuses to write without a record when the head needs a class), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged |
| `review_targeted` | the [targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code): the `classes` it covered, `rules`, `brief_sha`, the receipt's own `product_patch_id`, `status` (`done` only with its brief sent verbatim and its output collected on a committed diff, else `failed`; `write` refuses a record when a change after its read commit carries a targeted class or the read commit is not an ancestor, or when its product patch differs and the change is more than plain docs (no binary file and no numeric, results, prereg, eval, money, security, secret or auth claim; GST-1195), and refuses to write without a record when the head needs a class), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged |
Each review row may carry `dispatches: [{role, effort, prompt_bytes,
diff_bytes, status}]`, and each finding `raised_by` (the reviewer that raised it,
@@ -562,7 +567,8 @@ class the lane added shows apart from what the matchers found) and the queue ref
(capy-ai[bot]'s review id, reviewer and head); write and the queue accept it only under
`review.platform on`, on a docs or tests diff with no numeric, results or prereg claims,
with the review at that exact head, so a new head needs `/ship` again. A stop the owner
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
accepted after a fix-check edit, or after the second full pass with a finding
left open (`second_pass`), goes in `review_loop_exception`, never in prose
under `REVIEW_LOOP:`. Step 16 takes the same record:
`gstack-ship-release finish --review-loop-exception <json|@file>` routes the
paths the branch touched at `fix_check.head` (narrowed to the record's
@@ -578,9 +584,11 @@ completed clean read on committed content (freshness `CURRENT` against its own
tree) whose commit is on the branch, whose `range_base` is a branch commit
holding the reviewed tree, and for which an approved `review_loop_grant` in
`gstack-approvals` names a stop head inside `range_base..commit`, each code or test path inside `range_base..commit`
that is unchanged since routes as `fix_checked=` in `ROUTE:` and is listed on a
that is unchanged since, or whose change against the base is still the one the
fix-check read after a base merge also edited it (GST-1195), routes as
`fix_checked=` in `ROUTE:` and is listed on a
`FIX_CHECK: reviewed` line, a gate-edited test included. Authored docs keep the
stage 2.2 recheck, and a path edited after the fix-check still routes by the
stage 2.2 recheck, and a path the branch edited after the fix-check still routes by the
rules above. Any other row prints
`FIX_CHECK: not counted (<reason>)` and changes nothing. `gstack-review-log`
refuses a `review-fix-check` row without boolean `completed` and `converged`,
+8 -4
View File
@@ -1313,13 +1313,17 @@ fix-check edit under frozen scope (#3559), from `write --review-loop-exception
It needs `--review-loop` with `fix_checks` of 1 or more, a `fix_check.head`
that is the receipt head or its ancestor, and, with `--body`, the line
`Review-loop exception: accepted by <owner> <date> via <ref>: <reason> (fix-check at <sha12>, <n> findings)`
word for word; otherwise
word for word. A stop after the second full pass that left a finding open
(TRUT-7) takes `second_pass: { head, deferred: [<finding id>] }` in place of
`fix_check`, with `--review-loop` counting 2 or more passes and 1 or more
deferred, and the line ends `(second full pass at <sha12>, deferred <ids>)`;
finish does not take it, since it accepts an open finding, not edits. Otherwise
[`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid)
and nothing is written. The queue checks both owner exceptions the same way
before READY and train build: the record must parse, the body must still
carry its line, and the fix-check head must be the bound head or its ancestor
(after a carry across rewritten history the old fix-check commit is off the
branch, so that check is skipped), or the receipt is
carry its line, and the exception's head (the fix-check's or the second
pass's) must be the bound head or its ancestor (after a carry across rewritten
history the old commit is off the branch, so that check is skipped), or the receipt is
[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid).
**Prose against the receipt.** `gstack-ship-receipt read --pr <n> --check-prose`
+21 -11
View File
@@ -3435,10 +3435,14 @@ also refuses with this code, and writes nothing, when `--head` is not the
checked-out HEAD: the receipt binds the tree the review read. It refuses the
same way when the head touches money, gate, auth or secret code and no done
targeted record covers those classes (`REVIEW_TARGETED: … verdict=missing|failed`),
or when `--review-targeted` names a read commit whose product patch is not
the receipt's and a change after it carries a targeted class (money, gate,
auth or secret code changed after the targeted reader read it, a test edit the
gate scan tags included), or that is not an ancestor of HEAD.
or when `--review-targeted` names a read commit that is not an ancestor of
HEAD, or after which a change carries a targeted class (money, gate, auth or
secret code changed after the targeted reader read it, a test edit the gate
scan tags included), or whose product patch is not the receipt's and the change
since it is more than plain docs. A docs-only change after the read carries the
record only when it changes no binary file, adds no numeric claim, touches no
results, prereg or eval path, and names no money, security, secret or auth topic; any other docs change
needs the reader again (GST-1195).
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
block. For the `--head` refusal, check out that commit and write there. For a
@@ -5842,8 +5846,10 @@ Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-de
**Meaning.** The receipt records an owner exception (`docs.exception` or
`review_loop_exception`) that the queue cannot accept: the record does not
parse, a review-loop exception names no fix-check (`review_loop.fix_checks`
is 0 or missing), its `fix_check.head` is neither the bound head nor an
parse, a review-loop exception accepts a stop the review loop does not show (a
`fix_check` with `review_loop.fix_checks` 0 or missing, or a `second_pass`
with fewer than two passes or no deferred finding), its head (`fix_check.head`
or `second_pass.head`) is neither the bound head nor an
ancestor of it (not checked after a carry across rewritten history), or the PR
body no longer carries the exception's line word for word
(`Documentation exception: …` or `Review-loop exception: …`).
@@ -6183,12 +6189,16 @@ check it out and carry there.
### `REVIEW_LOOP_EXCEPTION_INVALID`
**Meaning.** `gstack-ship-receipt write --review-loop-exception` refused the
record and wrote nothing: it is not `{ accepted_by, date, via, reason,
fix_check: { head, findings[, files] } }`, `--review-loop` is missing or counts
no fix-check, `fix_check.head` is neither the receipt head nor its ancestor,
or the `--body` file lacks the `Review-loop exception: …` line.
record and wrote nothing: it is not `{ accepted_by, date, via, reason }` with
exactly one of `fix_check: { head, findings[, files] }` (a stop after a
fix-check) or `second_pass: { head, deferred: [<finding id>] }` (a stop after
the second full pass left a finding open, TRUT-7), `--review-loop` is missing
or does not show that stop (no fix-check counted, or fewer than two passes or
no deferred finding), the head is neither the receipt head nor its ancestor, or
the `--body` file lacks the `Review-loop exception: …` line.
**Fix.** Record the owner's exact words and the fix-check the stop followed,
**Fix.** Record the owner's exact words and the stop they accepted (the
fix-check, or the second full pass with the deferred finding's id),
pass the `gstack-review-scope summary` file as `--review-loop`, put the printed
line in the PR body, and write again
([ship-policy.md](ship-policy.md#ship-receipt)).
+2 -2
View File
@@ -122,7 +122,7 @@ export interface ShipReceipt {
gstack_version?: string;
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[] } };
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 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. */
@@ -266,7 +266,7 @@ export const ARTIFACT_SCHEMAS = {
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['platform', 'light', 'standard', 'full'] }, mode: { enum: ['lean', 'panel', 'platform'] }, platform_review: { type: 'object', required: ['id', 'head', 'reviewer'], properties: { id: str, head: str, reviewer: str } }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } }, declared: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } }, detected: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data', 'eval', 'policy'] } } } },
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', 'fix_check'], 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 } } } },
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 } } } },
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' } } } } } },
},
+2 -2
View File
@@ -392,8 +392,8 @@ export const RELEASE_RESULT_CODES = {
},
REVIEW_LOOP_EXCEPTION_INVALID: {
anchor: 'review-loop-exception-invalid',
summary: 'write --review-loop-exception got an exception that is malformed, follows no fix-check (review_loop.fix_checks is 0 or missing), names a fix-check head that is not the receipt head or its ancestor, or is missing from the PR body',
fix: 'pass { accepted_by, date, via, reason, fix_check: { head, findings } } with the owner’s words, the --review-loop summary that counts the fix-check, and the printed "Review-loop exception: …" line in the body',
summary: 'write --review-loop-exception got an exception that is malformed, accepts a stop the review loop does not show (a fix_check with review_loop.fix_checks 0 or missing, or a second_pass with fewer than two passes or no deferred finding), names a head that is not the receipt head or its ancestor, or is missing from the PR body',
fix: 'pass { accepted_by, date, via, reason } plus fix_check: { head, findings } or second_pass: { head, deferred: [<finding id>] } with the owner’s words, the --review-loop summary that shows that stop, and the printed "Review-loop exception: …" line in the body',
},
RECEIPT_CARRY_TREE_MISMATCH: {
anchor: 'receipt-carry-tree-mismatch',
+5 -3
View File
@@ -396,14 +396,16 @@ export interface NextStep { step: Step; reason: string }
* one): after round 2, below-floor fixes finish with notes, and a blocker open or fixed
* there, or a gate-class edit there, stops for the owner, whose grant may add one scoped fix-check.
* The cap limits fix rounds, not Step 11's voices: on gate class any stop waits for them (GST-967),
* and the owner's recorded grant (`granted`) turns the stop after a round-2 full pass with a fixed finding into its
* one fix-check; a fix-check never earns another, so the grant cannot repeat.
* and the owner's recorded grant (`granted`) turns the stop after a round-2 full pass with a fixed finding, or after a round-2
* fix-check when `fixChecks` says it was the only one (GEVA-223), into its one fix-check; a fix-check never earns another, so
* the grant cannot repeat.
*/
export function nextStep(h: History, depth?: Depth): NextStep {
const n = h.lean ? leanStep(h, depth) : loopStep(h, depth);
if (n.step !== 'stop') return n;
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's ${h.lean ? 'one reader' : 'voices (adversarial, outside, targeted)'} run${h.lean ? 's' : ''} before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
if (!h.lean && h.granted && !h.blockingOpen && h.last === 'full' && h.fullPasses === FULL_PASS_CAP) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), never a fourth round` };
const afterFixCheck = h.last === 'fix-check' && h.fullPasses === FULL_PASS_CAP - 1 && h.fixChecks === 1;
if (!h.lean && h.granted && !h.blockingOpen && (afterFixCheck || (h.last === 'full' && h.fullPasses === FULL_PASS_CAP))) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), ${afterFixCheck ? 'never a second' : 'never a fourth round'}` };
return n;
}
+58 -14
View File
@@ -25,8 +25,8 @@ import { collectCommit } from './review-collect';
import { reviewExclude, type Exclusion } from './review-exclude';
import { isUnavailable } from './ship-policy';
import { loadPolicyAt } from './ship-policy-at';
import { depthOfCommitInput, gateScanNeeded, platformFits, scanNumbers, type Depth } from './review-depth';
import type { RiskClass } from './review-risk';
import { classifyPath, depthOfCommitInput, gateScanNeeded, platformFits, scanNumbers, type Depth } from './review-depth';
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, MONEY_PATH_TOKENS, matchRisk, type RiskClass } from './review-risk';
import { platformReviewAt } from './review-platform';
import { reviewPolicy, type ReviewPolicy } from './ship-policy-review';
import { TARGETED_CLASSES, classList, targetedClasses, type TargetedClass } from './review-targeted';
@@ -146,14 +146,28 @@ export function taggedPaths(jsonl: string): Set<string> {
export const depthBelow = (have: Depth | 'platform', need: Depth | 'platform') => RANK[have] < RANK[need];
export const deeper = <D extends Depth | 'platform'>(a: D, b: D): D => (RANK[a] >= RANK[b] ? a : b);
/** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */
/**
* `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the stop it accepts:
* the fix-check it followed (`fix_check`), or the second full pass that left a finding open (`second_pass`, the deferred
* finding ids; TRUT-7).
*/
export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } {
const bad = (message: string) => ({ ok: false as const, message });
if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_loop_exception must be { accepted_by, date, via, reason, fix_check: { head, findings[, files] } }');
if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_loop_exception must be { accepted_by, date, via, reason, fix_check: { head, findings[, files] } | second_pass: { head, deferred: [<finding id>] } }');
const v = value as Record<string, unknown>;
for (const key of Object.keys(v)) if (!['accepted_by', 'date', 'via', 'reason', 'fix_check'].includes(key)) return bad(`review_loop_exception: unknown key ${JSON.stringify(key)}`);
for (const key of Object.keys(v)) if (!['accepted_by', 'date', 'via', 'reason', 'fix_check', 'second_pass'].includes(key)) return bad(`review_loop_exception: unknown key ${JSON.stringify(key)}`);
for (const key of ['accepted_by', 'date', 'via', 'reason']) if (!text(v[key])) return bad(`review_loop_exception.${key} must be a non-empty one-line string`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(v.date as string)) return bad('review_loop_exception.date must be YYYY-MM-DD');
if ((v.fix_check === undefined) === (v.second_pass === undefined)) return bad('review_loop_exception names exactly one of fix_check or second_pass: the stop it accepts');
const accepted = { accepted_by: (v.accepted_by as string).trim(), date: v.date as string, via: (v.via as string).trim(), reason: (v.reason as string).trim() };
if (v.second_pass !== undefined) {
const s = v.second_pass as Record<string, unknown> | null;
if (!s || typeof s !== 'object' || Array.isArray(s)) return bad('review_loop_exception.second_pass must be { head, deferred: [<finding id>] }');
for (const key of Object.keys(s)) if (!['head', 'deferred'].includes(key)) return bad(`review_loop_exception.second_pass: unknown key ${JSON.stringify(key)}`);
if (!sha(s.head)) return bad('review_loop_exception.second_pass.head must be the full commit the second full pass reviewed');
if (!Array.isArray(s.deferred) || !s.deferred.length || !s.deferred.every(text)) return bad('review_loop_exception.second_pass.deferred must name the open finding the owner accepted by id');
return { ok: true, exception: { ...accepted, second_pass: { head: s.head, deferred: (s.deferred as string[]).slice(0, 20).map(d => d.trim()) } } };
}
const f = v.fix_check as Record<string, unknown> | null;
if (!f || typeof f !== 'object' || Array.isArray(f)) return bad('review_loop_exception.fix_check must be { head, findings[, files] }');
for (const key of Object.keys(f)) if (!['head', 'findings', 'files'].includes(key)) return bad(`review_loop_exception.fix_check: unknown key ${JSON.stringify(key)}`);
@@ -161,12 +175,22 @@ export function parseReviewLoopException(value: unknown): { ok: true; exception:
if (typeof f.findings !== 'number' || !Number.isInteger(f.findings) || f.findings < 0) return bad('review_loop_exception.fix_check.findings must be an integer >= 0 (what the fix-check reported)');
if (f.files !== undefined && (!Array.isArray(f.files) || !f.files.every(text))) return bad('review_loop_exception.fix_check.files must be a list of paths');
const files = (f.files as string[] | undefined)?.slice(0, 50);
return { ok: true, exception: { accepted_by: (v.accepted_by as string).trim(), date: v.date as string, via: (v.via as string).trim(), reason: (v.reason as string).trim(), fix_check: { head: f.head, findings: f.findings, ...(files?.length ? { files } : {}) } } };
return { ok: true, exception: { ...accepted, fix_check: { head: f.head, findings: f.findings, ...(files?.length ? { files } : {}) } } };
}
/** The commit the accepted stop reviewed: the fix-check's head or the second full pass's. */
export const exceptionHead = (e: ReviewLoopException) => (e.fix_check ?? e.second_pass)!.head;
/** Whether the review loop shows the stop the exception accepts: a fix-check ran, or two full passes left a finding deferred. */
export function exceptionLoopProblem(e: ReviewLoopException, loop: ShipReceipt['review_loop']): string | null {
if (e.fix_check) return loop?.fix_checks ? null : 'needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check';
return (loop?.passes ?? 0) >= 2 && (loop?.deferred ?? 0) >= 1 ? null : 'second_pass needs --review-loop with passes >= 2 and deferred >= 1: the stop it accepts follows the second full pass with a finding left open';
}
/** The line the PR body must carry, word for word, while the exception stands. */
export function reviewLoopExceptionLine(e: ReviewLoopException): string {
return `Review-loop exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason} (fix-check at ${e.fix_check.head.slice(0, 12)}, ${e.fix_check.findings} finding${e.fix_check.findings === 1 ? '' : 's'})`;
const stop = e.fix_check ? `fix-check at ${e.fix_check.head.slice(0, 12)}, ${e.fix_check.findings} finding${e.fix_check.findings === 1 ? '' : 's'}` : `second full pass at ${e.second_pass!.head.slice(0, 12)}, deferred ${e.second_pass!.deferred.join(', ')}`;
return `Review-loop exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason} (${stop})`;
}
const carries = (body: string, line: string) => body.split(/\r?\n/).some(l => l.trim() === line);
@@ -187,8 +211,9 @@ export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (co
const e = parseReviewLoopException(r.review_loop_exception);
if (!e.ok) out.push(e.message);
else {
if (!r.review_loop?.fix_checks) out.push('review_loop_exception needs review_loop.fix_checks >= 1 (the stop it accepts follows a fix-check)');
if (isAncestor && !isAncestor(e.exception.fix_check.head)) out.push(`review_loop_exception.fix_check.head ${e.exception.fix_check.head.slice(0, 12)} is not the receipt head or an ancestor of it`);
const loopProblem = exceptionLoopProblem(e.exception, r.review_loop);
if (loopProblem) out.push(`review_loop_exception ${loopProblem.replace('--review-loop', 'review_loop')}`);
if (isAncestor && !isAncestor(exceptionHead(e.exception))) out.push(`review_loop_exception's head ${exceptionHead(e.exception).slice(0, 12)} is not the receipt head or an ancestor of it`);
const line = reviewLoopExceptionLine(e.exception);
if (!carries(body, line)) out.push(`the body does not carry the review-loop exception line verbatim: ${JSON.stringify(line)}`);
}
@@ -201,11 +226,12 @@ export type ReviewTargeted = NonNullable<ShipReceipt['review_targeted']>;
/**
* `write --review-targeted` input (gstack-review-dispatch verify --targeted-out), bound to the receipt's product patch-id here.
* `head` is the commit the reader read; `productPatchOf` gives that commit's product patch-id by the receipt's own rules. The
* record binds only on an equal product patch, and with `classesSince` also only when nothing changed after the read carries a
* targeted class (a test change the gate scan tags is gate class though the product patch leaves tests out), so a record never
* vouches for work it did not read (GST-986).
* record binds only on an equal product patch, or with `plainDocs` across a change after the read that is plain docs
* (plainDocsSince, GST-1195), and with `classesSince` also only when nothing changed after the read carries a targeted class (a
* test change the gate scan tags is gate class though the product patch leaves tests out), so a record never vouches for work it
* did not read (GST-986).
*/
export function parseReviewTargeted(value: unknown, productPatchId: string, productPatchOf: (head: string) => string | undefined, classesSince?: (read: string) => readonly TargetedClass[] | null): { ok: true; targeted: ReviewTargeted } | { ok: false; message: string } {
export function parseReviewTargeted(value: unknown, productPatchId: string, productPatchOf: (head: string) => string | undefined, classesSince?: (read: string) => readonly TargetedClass[] | null, plainDocs?: (read: string) => boolean): { ok: true; targeted: ReviewTargeted } | { ok: false; message: string } {
const bad = (message: string) => ({ ok: false as const, message });
if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_targeted must be { classes, rules, brief_sha, status, findings, raised_by, head }');
const v = value as Record<string, unknown>;
@@ -217,7 +243,7 @@ export function parseReviewTargeted(value: unknown, productPatchId: string, prod
if (typeof v.findings !== 'number' || !Number.isInteger(v.findings) || v.findings < 0) return bad('review_targeted.findings must be an integer >= 0');
if (typeof v.head !== 'string' || !/^[0-9a-f]{40,64}$/.test(v.head)) return bad('review_targeted.head must be the commit the targeted reader read (rerun gstack-review-dispatch verify --targeted-out with this gstack)');
const read = productPatchOf(v.head);
if (read !== productPatchId || (classesSince && classesSince(v.head)?.length !== 0)) return bad(`the targeted reader read ${v.head.slice(0, 12)}, whose product patch ${read?.slice(0, 12) ?? 'cannot be read'} is not this receipt's ${productPatchId.slice(0, 12)}: the product changed after the read, so run the targeted reader on HEAD and verify again`);
if ((read !== productPatchId && !plainDocs?.(v.head)) || (classesSince && classesSince(v.head)?.length !== 0)) return bad(`the targeted reader read ${v.head.slice(0, 12)}, whose product patch ${read?.slice(0, 12) ?? 'cannot be read'} is not this receipt's ${productPatchId.slice(0, 12)}: the product changed after the read (only a docs change with no numeric, results, prereg, eval, money or security claim carries the record), so run the targeted reader on HEAD and verify again`);
const classes = TARGETED_CLASSES.filter(c => (v.classes as string[]).includes(c));
return { ok: true, targeted: { classes, rules: v.rules as string, brief_sha: v.brief_sha, product_patch_id: productPatchId, status: v.status as ReviewTargeted['status'], findings: v.findings, raised_by: (v.raised_by as string).trim() } };
}
@@ -346,6 +372,24 @@ export function targetedClassesSince(root: string, from: string, head: string):
return t.errors.length ? null : t.classes;
}
/**
* Whether what changed from `from` to the checked-out `head` is plain docs, which a targeted record carries across (GST-1195):
* every path docs class, R65's claim rule (platformFits: no numeric claim, no results, prereg or eval path), and no money,
* security, secret or auth wording in the changed lines (the risk matchers plus DOCS_CLAIM_WORDS, since the matchers spare prose).
* False when `from` is not an ancestor or the diff is unreadable.
*/
export function plainDocsSince(root: string, from: string, head: string): boolean {
if (!git(root, ['merge-base', '--is-ancestor', from, head]).ok) return false;
const c = collectCommit(root, from, head);
if (c.errors.length || !c.paths.length || !c.paths.every(p => classifyPath(p, c.root) === 'docs') || c.perPath.some(p => p.binary)) return false;
const lines = c.paths.flatMap(p => [...(c.added?.[p] ?? []), ...(c.removed?.[p] ?? [])]);
if (lines.some(l => l.toLowerCase().split(/[^a-z0-9]+/).some(t => DOCS_CLAIM_WORDS.has(t) || AUTH_PATH_PREFIXES.some(a => t.startsWith(a))))) return false;
return platformFits('docs', c.paths, 0, scanNumbers(c.added ?? {})) && matchRisk({ codePaths: c.paths, added: c.added, removed: c.removed, scopeAuth: true }).size === 0;
}
/** Words that make a docs line a money, security, secret or auth claim for plainDocsSince: deliberately broad, since a false hit only reruns the reader. */
const DOCS_CLAIM_WORDS = new Set([...MONEY_PATH_TOKENS, ...AUTH_PATH_TOKENS, 'cap', 'caps', 'capped', 'usd', 'dollar', 'dollars', 'paid', 'pay', 'payment', 'payments', 'refund', 'refunds',
'secret', 'secrets', 'key', 'keys', 'credential', 'credentials', 'password', 'passwords', 'token', 'tokens', 'security', 'sandbox', 'injection', 'trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape', 'csrf', 'xss', 'vulnerability', 'privilege']);
/**
* receiptTargetedVerdict at the receipt's head, with the classes the queue computes: what write and carry check before they
* publish (#3854). Classes that failed closed on an input this checkout cannot read (no origin base, no gate scan) are reported,
+14 -7
View File
@@ -198,8 +198,9 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
* 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.
*/
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted' | 'fix_checked', string[]>;
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException }, r: Report): Routed | null {
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
const changedSet = touchedPaths(cwd, reviewed, now);
@@ -253,7 +254,9 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
if (counted) {
const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now);
if (!range || !after) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; }
by.fix_checked = [...by.behavior, ...by.tests, ...by.context].filter(p => range.has(p) && !after.has(p)).sort();
const fixSides = { reviewedBase: git(cwd, ['merge-base', baseRef, fixCommit]).stdout.trim(), reviewed: fixCommit, currentBase: sides?.currentBase ?? git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim(), current: now };
const unmoved = (p: string) => !after.has(p) || (!!fixSides.reviewedBase && !!fixSides.currentBase && comparePath(cwd, fixSides, p) === 'same');
by.fix_checked = [...by.behavior, ...by.tests, ...by.context].filter(p => range.has(p) && unmoved(p)).sort();
for (const k of ['behavior', 'tests', 'context'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p));
r.add(`FIX_CHECK: reviewed ${by.fix_checked.length ? short(by.fix_checked) : 'none'} (fix-check at ${fixCommit.slice(0, 12)}, range from ${fixFrom.slice(0, 12)}, grant ${grant!.id})`);
}
@@ -333,6 +336,7 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
const nul = (out: string) => out.split('\0').filter(Boolean);
const auditBase = git(cwd, ['merge-base', baseRef, record.head]).stdout.trim();
const nowBase = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
const nowTree = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
const atAudit = auditBase ? touchedPaths(cwd, auditBase, record.head) : new Set<string>();
const nowDiff = nowBase ? git(cwd, ['diff', '-z', '--name-only', '--no-renames', nowBase]) : null;
if (!atAudit || (nowDiff && nowDiff.status !== 0)) { r.stop(`git could not list the paths the branch touched against ${baseRef}; documentation freshness is unknown (stage 3)`); return false; }
@@ -355,7 +359,8 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
});
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[], accepted: [] as string[] };
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p))
|| (!!auditBase && !!nowBase && /^[0-9a-f]{40,64}$/.test(nowTree) && ![...record.staged, ...record.unstaged, ...record.untracked].includes(p) && comparePath(cwd, { reviewedBase: auditBase, reviewed: record.head, currentBase: nowBase, current: nowTree }, p) === 'same'))), requested: [] as string[], accepted: [] as string[] };
carried.accepted = stale.filter(p => loopAccepted?.includes(p) && !carried.tests.includes(p) && !carried.base.includes(p));
let audit: any = null;
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
@@ -395,16 +400,18 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
const base = flag('--base')!;
const r = new Report();
const list = lanes(argv) as { label: string; command: string }[];
let loop: ReviewLoopException | undefined;
let loop: FixCheckException | undefined;
const loopArg = flag('--review-loop-exception');
if (loopArg !== undefined) {
let raw: unknown;
try { raw = JSON.parse(loopArg.startsWith('@') ? fs.readFileSync(path.resolve(cwd, loopArg.slice(1)), 'utf8') : loopArg); } catch (e) { return r.stop(`--review-loop-exception: ${firstLine((e as Error).message)}`); }
const parsed = parseReviewLoopException(raw);
if (!parsed.ok) return r.stop(`--review-loop-exception: ${parsed.message}`);
if (Array.isArray((raw as { fix_check?: { files?: unknown } }).fix_check?.files) && !parsed.exception.fix_check.files) return r.stop('--review-loop-exception: fix_check.files lists no paths; omit it to accept the paths the branch touched at fix_check.head');
if (git(cwd, ['merge-base', '--is-ancestor', parsed.exception.fix_check.head, 'HEAD']).status !== 0) return r.stop(`--review-loop-exception: fix_check.head ${parsed.exception.fix_check.head.slice(0, 12)} is not HEAD or an ancestor of it`);
loop = parsed.exception;
const fixCheck = parsed.exception.fix_check;
if (!fixCheck) return r.stop('--review-loop-exception: finish routes edits made after a fix-check; a second_pass exception accepts an open finding, not edits, so run finish without it and pass it to gstack-ship-receipt write');
if (Array.isArray((raw as { fix_check?: { files?: unknown } }).fix_check?.files) && !fixCheck.files) return r.stop('--review-loop-exception: fix_check.files lists no paths; omit it to accept the paths the branch touched at fix_check.head');
if (git(cwd, ['merge-base', '--is-ancestor', fixCheck.head, 'HEAD']).status !== 0) return r.stop(`--review-loop-exception: fix_check.head ${fixCheck.head.slice(0, 12)} is not HEAD or an ancestor of it`);
loop = { ...parsed.exception, fix_check: fixCheck };
}
const policy = shipPolicy(cwd, base);
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD', base), flag('--trailer'), r)) return r;
+1 -1
View File
@@ -1 +1 @@
{"sha":"4685626e3f25b8264a4c68181de593b7d923a676428beb4dd34fe42bb9fb4073","replay":"GSTA-258-L7+GST-961+962+GST-972"}
{"sha":"706cf48d3cfac586484c68160e292955752c55ed14bdc06856be0ef8ac5e61c6","replay":"GSTA-258-L7+GST-961+962+GST-972+GEVA-223"}
+1
View File
@@ -0,0 +1 @@
{"replay":"GEVA-223","justification":"#3970 (GST-1195 bundle, Captain ruling A): lib/review-depth.ts is main's GSTA-258-C+GST-940 bytes plus one transition in nextStep, a recorded grant's one fix-check after a round-2 fix-check when --fix-checks says exactly one ran (GEVA-223, GST-1246). Deterministic no-loss replay, no paid run: the GST-967 dispatch-superset no-loss check over the 1,440-case grid with no grant passed still hashes to MAIN_PLANS 9d13d6bae86bfc9207ce7e0cec8da5b33db2518c1653e8e009160a98b0f04c89, and the GST-967 transition-table invariant (every state, grant or not) passes (test/review-depth.test.ts); without a grant and --fix-checks 1 no plan, depth, class or dispatch changes, and reviewer briefs are untouched"}
@@ -0,0 +1 @@
{"replay":"GSTA-258-L7+GST-961+962+GST-972+GEVA-223","justification":"rule 19 merge of main 38b7a23 (#3942 and #3959 landed, pin GSTA-258-L7+GST-961+962+GST-972) into #3970 (GST-1195, GEVA-223): git merged lib/review-depth.ts with no conflicted line; the merged file minus main is exactly the GEVA-223 nextStep transition (a recorded grant's one fix-check after a round-2 fix-check when fixChecks is 1) and its doc comment, so this repin relies on the GEVA-223 no-loss replay and main's GSTA-258-L7+GST-961+962+GST-972 justification, neither rerun on the merged file; their focused tests (test/review-depth.test.ts, test/review-depth-lean.test.ts, test/review-packets-removed.test.ts) pass on the merged tree (GSTA-268)"}
+11
View File
@@ -100,6 +100,17 @@ describe('GST-967: the round cap limits fix rounds, not the gate-class voices; a
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', granted: 'a-1' }).step).toBe('done');
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop');
});
// Value: protects=a recorded grant gets its one fix-check when round 2 was itself a fix-check (GEVA-223), without restating
// history as a full pass; fails_when=the planner prints stop despite the grant, runs a second granted fix-check, or grants
// one with a blocker open; seam=nextStep; no_claim=the CLI's grant binding to the stop head
test('a recorded grant after a round-2 fix-check runs its one fix-check, and never a second (GEVA-223)', () => {
const h = { fullPasses: 1, last: 'fix-check', fixedBlocking: true, granted: 'a-a17f3fd0a47a', fixChecks: 1 } as const;
expect(nextStep(h)).toMatchObject({ step: 'fix-check', reason: expect.stringContaining('a-a17f3fd0a47a') });
expect(nextStep({ ...h, fixedBlocking: false, touchedGate: true }).step).toBe('fix-check');
expect(nextStep({ ...h, gate: true }).step).toBe('voices');
expect(nextStep({ ...h, gate: true, adversarialRan: true }).step).toBe('fix-check');
for (const o of [{ fixChecks: 2 }, { fixChecks: undefined }, { blockingOpen: true }, { granted: undefined }, { fullPasses: FULL_PASS_CAP }]) expect([o, nextStep({ ...h, ...o }).step]).toEqual([o, 'stop']);
});
test('docs/review-policy.md\'s transitions table and rule 3 say both', () => {
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'review-policy.md'), 'utf8');
const table = doc.slice(doc.indexOf('<a id="transitions"></a>'), doc.indexOf('A **fix-check** is one reviewer'));
+53
View File
@@ -339,6 +339,59 @@ describe('the targeted role end to end on a fixture repository', () => {
}, 180_000);
});
// Value: protects=a targeted record carries across a plain docs-only commit after the read, as docs/troubleshooting.md says
// (GST-1195, truther#1168: designs/ and designs/research/*.md after the money reader); fails_when=write refuses the record on
// that commit, or carries one across a docs commit that adds a numeric claim, sits on a results/eval path, or names money or
// secrets; seam=bin/gstack-ship-receipt write --review-targeted; no_claim=the queue's own targeted verdict
describe('a targeted record across a docs-only delta (GST-1195, TRUT-26)', () => {
const setup = () => {
const repo = makeFixtureRepo({ prefix: 'targeted-docs-' });
repos.push(repo);
repo.git('checkout', '-q', '-b', 'feat');
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.mkdirSync(path.join(repo.work, 'designs', 'research'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 5;\n');
fs.writeFileSync(path.join(repo.work, 'designs', '08-house-agent.md'), '# House agent\n\nThe agent answers questions.\n');
fs.writeFileSync(path.join(repo.work, 'designs', 'research', 'notes.md'), '# Notes\n\nOpen questions.\n');
fs.writeFileSync(path.join(repo.work, 'designs', 'research', 'dfi-evals.md'), '# Evals\n\nHow the cases run.\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'charge');
const head = repo.git('rev-parse', 'HEAD');
const record = JSON.stringify({ classes: ['money'], rules: TARGETED_RULES, brief_sha: targetedBriefSha(), status: 'done', findings: 0, raised_by: 'targeted', head });
const write = () => runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--review-targeted', record, '--json']);
const docsCommit = (files: Record<string, string>) => {
repo.git('reset', '-q', '--hard', head);
for (const [p, text] of Object.entries(files)) fs.writeFileSync(path.join(repo.work, p), text);
repo.git('commit', '-qam', 'docs after the targeted read');
};
return { repo, head, write, docsCommit };
};
test('a plain docs commit under designs/ and designs/research/ carries the record', () => {
const { head, write, docsCommit } = setup();
docsCommit({ 'designs/08-house-agent.md': '# House agent\n\nThe agent answers questions about a house.\n', 'designs/research/notes.md': '# Notes\n\nOpen questions, grouped by topic.\n' });
const w = write();
expect([w.status, w.err]).toEqual([0, expect.not.stringContaining(`the targeted reader read ${head.slice(0, 12)}`)]);
expect(JSON.parse(w.out).review_targeted).toMatchObject({ classes: ['money'], status: 'done' });
}, 60_000);
test('a docs commit with a numeric claim, on an eval path, or naming money or secrets does not carry it', () => {
const { head, write, docsCommit } = setup();
for (const files of [
{ 'designs/08-house-agent.md': '# House agent\n\nThe agent answers 40% more questions.\n' },
{ 'designs/research/dfi-evals.md': '# Evals\n\nHow the cases run, one runner each.\n' },
{ 'designs/08-house-agent.md': '# House agent\n\nThe agent no longer checks the spend cap.\n' },
{ 'designs/research/notes.md': '# Notes\n\nThe API key now lives in the repo.\n' },
{ 'designs/08-house-agent.md': '# House agent\n\nThe service no longer checks the spend cap and prints credentials.\n\0' },
{ 'designs/08-house-agent.md': '# House agent\n\nAuthentication now happens later.\n' },
{ 'designs/research/notes.md': '# Notes\n\nThis skips the CSRF check for authorized users.\n' },
] as Record<string, string>[]) {
docsCommit(files);
const w = write();
expect([Object.keys(files)[0], w.status === 0, w.err.includes(`the targeted reader read ${head.slice(0, 12)}`)]).toEqual([Object.keys(files)[0], false, true]);
}
}, 120_000);
});
describe('the /ship commands carry the targeted record to the receipt (GST-464)', () => {
// Value: protects=when the targeted reader ran, the receipt /ship writes carries its record, so ready prints verdict=ok instead
// of receipt=legacy (gstack-evals#95, #3682, #3612); fails_when=Step 11's verify writes the record nowhere Step 19 reads, the
+35
View File
@@ -212,6 +212,41 @@ describe('the review-loop exception (#3559)', () => {
}, 90_000);
});
describe('the review-loop exception after the second full pass (TRUT-7, truther#1169)', () => {
// Value: protects=an owner who accepts an open finding under the two-round cap has a receipt path when /ship stopped after the
// second full pass (passes=2 blocking_found=1 deferred=1, no fix-check); fails_when=write refuses that stop, accepts one with
// fewer than two passes, no deferred finding, no finding id, both stop kinds or no body line, or the queue ignores the line;
// seam=gstack-ship-receipt write and bindShippedReceipt; no_claim=finish's routing
test('write records it with the deferred finding named by id; a stop that is neither kind is still refused', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const head = commit(repo, { 'lib/util.js': lines(3) });
const body = path.join(repo.root, 'body.md');
const exception = { accepted_by: 'TRUT-1', date: '2026-11-03', via: 'TRUT-7 relay', reason: 'the open finding ships as a note under the two-round cap', second_pass: { head, deferred: ['specialist#3'] } };
const arg = (e: unknown) => ['--review-loop-exception', JSON.stringify(e)];
const loop = (passes: number, deferred: number) => ['--review-loop', JSON.stringify({ passes, blocking_found: 1, deferred, noted: 2, fixed: 2, scope: 'frozen' })];
fs.writeFileSync(body, '## What changed\n');
for (const [extra, why] of [
[[...arg(exception), ...loop(1, 1)], 'passes >= 2'],
[[...arg(exception), ...loop(2, 0)], 'deferred >= 1'],
[[...arg({ ...exception, second_pass: { head, deferred: [] } }), ...loop(2, 1)], 'second_pass.deferred must name'],
[[...arg({ ...exception, fix_check: { head, findings: 1 } }), ...loop(2, 1)], 'exactly one of fix_check or second_pass'],
[[...arg({ ...exception, second_pass: { head: 'a'.repeat(40), deferred: ['specialist#3'] } }), ...loop(2, 1)], 'is not ' + head.slice(0, 12) + ' or an ancestor of it'],
[[...arg(exception), ...loop(2, 1)], 'does not carry the exception line verbatim'],
] as Array<[string[], string]>) {
const out = writeReceipt(repo, body, extra);
expect([why, out.status, out.err.includes('(REVIEW_LOOP_EXCEPTION_INVALID;'), out.err.includes(why)]).toEqual([why, EXIT.fail, true, true]);
}
const line = reviewLoopExceptionLine(exception as never);
expect(line).toBe(`Review-loop exception: accepted by TRUT-1 2026-11-03 via TRUT-7 relay: the open finding ships as a note under the two-round cap (second full pass at ${head.slice(0, 12)}, deferred specialist#3)`);
fs.writeFileSync(body, `## What changed\n\n${line}\n`);
expect(writeReceipt(repo, body, [...arg(exception), ...loop(2, 1)]).status).toBe(EXIT.ok);
expect(receiptIn(body).review_loop_exception).toEqual(exception);
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]).ok).toBe(true);
const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]);
expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
}, 90_000);
});
describe('the queue and a scoped second pass (GSTA-152 D6)', () => {
// Value: protects=a receipt whose pass 2 was scoped (fix-check, Red Team, native adversarial; no specialists) READYs at the depth
// its head needs, with no schema change; fails_when=the queue reads review_loop.dispatches or passes as a depth signal; seam=bindShippedReceipt
+37
View File
@@ -859,6 +859,43 @@ describe('finish after a completed fix-check (GST-1130, GEVA-218)', () => {
expect(early.status).toBe(3);
});
// Value: protects=a base merge after the granted fix-check that also edits a fix-checked file keeps that file's credit when its
// patch against the base is the one the fix-check read (#3916 open note, merge 131a856); fails_when=finish compares the
// fix-check commit's tree with the current tree path by path and prints FIX_CHECK: reviewed none, or credits a branch edit
// made after the merge; seam=finish's route reading the review log; no_claim=a conflict resolution's meaning
test('a base merge that also edits a fix-checked file keeps the credit while the branch patch is unchanged', () => {
const f = fx({ 'src/x.ts': lines(9) }, { 'src/x.ts': lines(9, { 5: 'export const l5 = 50;' }), 'test/x.test.ts': TEST_V1 });
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = wtree(f);
const reviewedBase = mergeBase(f);
f.write({ 'src/x.ts': lines(9, { 5: 'export const l5 = 51;' }), 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker and bound its case');
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
const post = snapshot(f);
const id = grant(f, passHead);
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
f.advanceBase({ 'src/x.ts': lines(9, { 1: 'export const l1 = 100;' }), 'src/y.ts': 'export const y = 1;\n' });
mergeMain(f);
expect(f.git('show', 'HEAD:src/x.ts').stdout).toBe(lines(9, { 1: 'export const l1 = 100;', 5: 'export const l5 = 51;' }));
const r = finish(f, reviewed, post, ['--reviewed-base', reviewedBase]);
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 ${id})`);
expect([r.status, lineOf(r.stdout, 'ROUTE'), lineOf(r.stdout, 'DOCS')]).toEqual([0, expect.stringMatching(/ behavior=0 docs=0 metadata=0 tests=0 base=1 context=0 fix_checked=2$/), expect.stringMatching(/^DOCS: fresh .*carried: tests=0 base=1 /)]);
f.write({ 'src/x.ts': lines(9, { 1: 'export const l1 = 100;', 5: 'export const l5 = 52;' }) }); f.commit('an edit after the merge');
const late = finish(f, reviewed, post, ['--reviewed-base', reviewedBase]);
expect(lineOf(late.stdout, 'FIX_CHECK')).toStartWith('FIX_CHECK: reviewed test/x.test.ts (');
expect([late.status, late.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]);
});
// Value: protects=the docs audit carries a path only on the tree finish checks, so a --build that rewrites an audited doc after
// the commit check is still stale (#3970 outside voice); fails_when=the carry compares HEAD and prints DOCS: fresh;
// seam=finish's docsFresh; no_claim=push's own working-tree guard
test('a build that rewrites an audited doc after the commit check keeps the docs audit stale', () => {
const f = fx({ 'README.md': '# Fx\n' }, { 'README.md': '# Fx, from the branch\n' });
const reviewed = wtree(f);
const post = snapshot(f);
const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f), '--docs-rechecked', '--build', "printf '# Generated replacement never audited\\n' > README.md"]);
expect([r.status, lineOf(r.stdout, 'DOCS')]).toEqual([3, 'DOCS: stale (README.md)']);
});
test('a fix-check logged on uncommitted content is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
f.write({ 'src/x.ts': 'export const x = 9;\n' });