mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
e5366a52b7
commit
a66d2f460d
18 files changed
+288
-63
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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') });
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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' } } } } } },
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 @@
|
||||
{"sha":"4685626e3f25b8264a4c68181de593b7d923a676428beb4dd34fe42bb9fb4073","replay":"GSTA-258-L7+GST-961+962+GST-972"}
|
||||
{"sha":"706cf48d3cfac586484c68160e292955752c55ed14bdc06856be0ef8ac5e61c6","replay":"GSTA-258-L7+GST-961+962+GST-972+GEVA-223"}
|
||||
@@ -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"}
|
||||
+1
@@ -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)"}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user