mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.93.8.0 fix(ship): run Step 11's voices before any gate-class stop, and honor an owner's recorded grant (#3875)
* fix(review-depth): gate-class Step 11 voices run before any cap stop; --grant runs the owner's fix-check (GST-967) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-depth): GST-967 dispatch-superset no-loss check; repin review-depth and the two ship sections to it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-depth): GST-967 invariant over every transitions-table state Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-967 Step 11: grant row names review_loop_exception, a fix-check never earns another, voices route queued blockers to the plan) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fix (GST-967 round 2, granted a-a09a908e77c2): a grant names review_loop_grant and its stop head, bound by --range-base Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): keep the repeat decision's NEXT map sentence intact (GST-967) test/ship-control-flow.test.ts pins the sentence ending `done` 4.; the voices route now follows it as its own sentence. Repins review-army.md under GST-967 and moves the size ledger entry to +476. 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
fc292e22f5
commit
40a3170335
19 files changed
+279
-24
No files matched your search
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.93.8.0] - 2026-11-02
|
||||||
|
|
||||||
|
**/ship now runs Step 11's voices before any gate-class stop, and a recorded owner grant runs its fix-check.** On a gate-class change, a blocker fixed in review round 2 used to stop the run for the owner before Step 11 ran. If the owner then chose "ship with open notes", gate code shipped with no adversarial, outside or targeted review (C6-36 after-opus). `gstack-review-plan` now answers `NEXT: voices` for any gate-class stop while Step 11 has not run. /ship runs Step 11 as it does on `done`, asks the plan again with `--adversarial-ran`, and the stop then stands. The round cap still limits fix rounds; it no longer skips the required voices. Second, the owner's grant of one scoped fix-check after round 2 had no way in: the plan took no grant input, and the pass program printed `PASS: none next=stop`. Now `gstack-review-plan --grant <approval id>` checks that the branch's `gstack-approvals` record holds that id as an approved grant naming `review_loop_grant` and this run's stop head (exit 3 for any other row, a grant recorded for an earlier /ship on the branch included), and plans the one fix-check in place of the stop after a round-2 full pass. The pass program then dispatches that fix-check like any other. A fix-check never earns another, so the grant cannot repeat. After `NEXT: voices`, a blocking finding the voices queue goes back to the plan as `--blocking-open` instead of starting a new review pass.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `test/review-depth.test.ts`: a gate-class round-2 blocker fix plans `voices` (adversarial, outside and targeted) before every stop and stops once they ran. A grant plans one fix-check after round 2, after the voices on gate class, and never a second. The transitions table and rule 3 in `docs/review-policy.md` say both. All four failed at main 1022f4f.
|
||||||
|
- `test/review-plan-cli.test.ts`: `--grant` with a recorded grant prints `NEXT: fix-check`; an unrelated approval, an unrecorded id, a `review_loop_exception` acceptance row or a grant for an earlier head exits 3; after a fix-check the grant never plans another. Failed at main 1022f4f (`NEXT: stop`).
|
||||||
|
- `test/review-depth.test.ts`: an invariant over every transitions-table state (gate class or not, Step 11 run or not, grant or not) and a dispatch-superset no-loss check against main's plans.
|
||||||
|
- `test/review-pass.test.ts`: the pass program runs the granted fix-check reader instead of `PASS: none next=stop`. Failed at main 1022f4f.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- `docs/review-policy.md`: rule 3 and two new rows in the transitions table. `ship/DRIVER.md`, `ship/sections/review-army.md` and `ship/sections/adversarial.md` route `voices` and `--grant`.
|
||||||
|
|
||||||
## [1.93.7.0] - 2026-11-02
|
## [1.93.7.0] - 2026-11-02
|
||||||
|
|
||||||
**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit.
|
**A version stamp the branch added at its fork point's version now counts as stale after the base moves (GST-963).** On gbrain #8425, a branch added a row that named the fork point's version. After main moved past it, `gstack-restamp` skipped the row ("no tentative mention") and `gstack-ship-release finish` routed the line as a behavior change, so the stamp kept the old version and the docs stage flagged a product edit.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.93.7.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.93.8.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
+20
-3
@@ -5,7 +5,7 @@
|
|||||||
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
|
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
|
||||||
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
|
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
|
||||||
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
|
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
|
||||||
* [--targeted-ran <targeted_patch_id>] [--json]
|
* [--targeted-ran <targeted_patch_id>] [--grant <approval id>] [--json]
|
||||||
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
|
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
|
||||||
*
|
*
|
||||||
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
|
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { locate, readRecord } from '../lib/approval-record';
|
||||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||||
import { runScriptSync } from '../lib/script-invocation';
|
import { runScriptSync } from '../lib/script-invocation';
|
||||||
import { collectCommit, collectLive } from '../lib/review-collect';
|
import { collectCommit, collectLive } from '../lib/review-collect';
|
||||||
@@ -54,12 +55,17 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
|
|||||||
--pass1-depth <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
|
--pass1-depth <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
|
||||||
--targeted-ran <id> the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted
|
--targeted-ran <id> the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted
|
||||||
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
|
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
|
||||||
|
--grant <approval id> the owner's grant of one scoped fix-check after round 2, recorded with gstack-approvals add (approved,
|
||||||
|
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-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
||||||
|
|
||||||
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR;
|
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR;
|
||||||
standard only by --depth-floor or a raise; otherwise light. TARGETED: the money,
|
standard only by --depth-floor or a raise; otherwise light. TARGETED: the money,
|
||||||
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
|
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
|
||||||
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan
|
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, voices (gate class: Step 11 before any stop,
|
||||||
|
then ask again with --adversarial-ran), stop or done. A full plan
|
||||||
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
||||||
${exitTableHelp()}`;
|
${exitTableHelp()}`;
|
||||||
|
|
||||||
@@ -92,11 +98,22 @@ function main(): number {
|
|||||||
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
|
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
|
||||||
const floor = flag('--depth-floor') as Depth | undefined;
|
const floor = flag('--depth-floor') as Depth | undefined;
|
||||||
if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
|
if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
|
||||||
|
const grant = flag('--grant');
|
||||||
|
if (has('--grant') && (!grant || grant.startsWith('-'))) { process.stderr.write('gstack-review-plan: --grant needs the approval id gstack-approvals add printed\n'); return EXIT.usage; }
|
||||||
const rangeBase = flag('--range-base');
|
const rangeBase = flag('--range-base');
|
||||||
if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; }
|
if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; }
|
||||||
|
if (grant && !rangeBase) { process.stderr.write('gstack-review-plan: --grant needs --range-base (round 2\'s PASS_HEAD), which binds the grant to the stop it answers\n'); return EXIT.usage; }
|
||||||
const root = git(['rev-parse', '--show-toplevel'])?.trim();
|
const root = git(['rev-parse', '--show-toplevel'])?.trim();
|
||||||
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
|
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
|
||||||
process.chdir(root);
|
process.chdir(root);
|
||||||
|
if (grant) {
|
||||||
|
const loc = locate({ cwd: root, env: process.env });
|
||||||
|
const row = ('file' in loc ? readRecord(loc).rows : []).find(r => r.id === grant && r.outcome === 'approved' && `${r.ask} ${r.scope}`.includes('review_loop_grant'));
|
||||||
|
const passHead = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim();
|
||||||
|
const ancestor = (a: string, d: string) => git(['merge-base', '--is-ancestor', a, d]) !== null;
|
||||||
|
const stopHead = row && passHead ? `${row.ask} ${row.scope}`.match(/\b[0-9a-f]{7,40}\b/g)?.map(s => git(['rev-parse', '--verify', '--quiet', `${s}^{commit}`])?.trim()).find(s => s && ancestor(passHead, s) && ancestor(s, 'HEAD')) : undefined;
|
||||||
|
if (!stopHead) { process.stderr.write(`gstack-review-plan: --grant ${grant}: no approved row ${grant} naming review_loop_grant and this invocation's stop head (a commit at or after --range-base ${rangeBase}, at or before HEAD) in this branch's approval record (${'file' in loc ? 'gstack-approvals list' : loc.message}); record the owner's grant with gstack-approvals add --ask '... (review_loop_grant <stop head sha>)' first\n`); return EXIT.refused; }
|
||||||
|
}
|
||||||
const collected = collectLive(root, base);
|
const collected = collectLive(root, base);
|
||||||
if (collected.errors.length) {
|
if (collected.errors.length) {
|
||||||
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
|
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
|
||||||
@@ -145,7 +162,7 @@ function main(): number {
|
|||||||
const plan = planReview({
|
const plan = planReview({
|
||||||
...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
|
...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
|
||||||
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
|
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
|
||||||
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
|
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
|
||||||
});
|
});
|
||||||
const gateScan = { tagged: gateTagged, head };
|
const gateScan = { tagged: gateTagged, head };
|
||||||
if (has('--json')) {
|
if (has('--json')) {
|
||||||
|
|||||||
+19
-2
@@ -98,9 +98,19 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
|||||||
finishes with its open notes listed in the PR body and receipt, or stops for
|
finishes with its open notes listed in the PR body and receipt, or stops for
|
||||||
the owner when a blocking finding is open, or was fixed in round 2 and is
|
the owner when a blocking finding is open, or was fixed in round 2 and is
|
||||||
unverified, or a round-2 fix edited a gate-class path, naming it (path,
|
unverified, or a round-2 fix edited a gate-class path, naming it (path,
|
||||||
severity, problem). A third round is the owner's
|
severity, problem). The cap limits fix rounds, not Step 11's voices: on gate
|
||||||
|
class a stop waits for Step 11's adversarial, outside and targeted voices
|
||||||
|
(`NEXT: voices`), so gate code never reaches the owner's "ship with open
|
||||||
|
notes" without them (GST-967). A third round is the owner's
|
||||||
grant of one scoped fix-check (recorded in `review_loop_exception`), never the
|
grant of one scoped fix-check (recorded in `review_loop_exception`), never the
|
||||||
loop's. The cap approves nothing.
|
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,
|
||||||
|
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.
|
||||||
|
|
||||||
<a id="transitions"></a>
|
<a id="transitions"></a>
|
||||||
### Transitions
|
### Transitions
|
||||||
@@ -122,12 +132,19 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
|||||||
| a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner |
|
| a full pass that leaves a blocking finding open (fixed or not) | stop: name it for the owner |
|
||||||
| a fix-check that found nothing blocking | done |
|
| a fix-check that found nothing blocking | done |
|
||||||
| a fix-check that found a blocking finding | stop: name it for the owner |
|
| 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 **fix-check** is one reviewer reading the touched hunks, the fix list, the
|
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
|
test results and the callers of every changed symbol, at full effort. It is
|
||||||
logged as a `review-fix-check` row, so it never counts as a pass. No army, no
|
logged as a `review-fix-check` row, so it never counts as a pass. No army, no
|
||||||
Red Team and no outside voice run on it.
|
Red Team and no outside voice run on it.
|
||||||
|
|
||||||
|
`NEXT: voices` runs Step 11 as `done` does, from the last full pass's
|
||||||
|
manifest; /ship then asks the plan again with `--adversarial-ran` (a blocking
|
||||||
|
finding the voices queued counts as `--blocking-open`, never a new Step 9), and
|
||||||
|
the stop or the granted fix-check it names stands.
|
||||||
|
|
||||||
<a id="scoped-pass"></a>
|
<a id="scoped-pass"></a>
|
||||||
A **scoped pass** is pass 2 after a blocking fix when pass 1 and the diff both
|
A **scoped pass** is pass 2 after a blocking fix when pass 1 and the diff both
|
||||||
ran below full depth (no gate class, no depth error, no `--depth-floor full`).
|
ran below full depth (no gate class, no depth error, no `--depth-floor full`).
|
||||||
|
|||||||
+23
-3
@@ -20,7 +20,7 @@ export type PathClass = 'docs' | 'tests' | 'code';
|
|||||||
export type Depth = 'light' | 'standard' | 'full';
|
export type Depth = 'light' | 'standard' | 'full';
|
||||||
export type Effort = 'light' | 'medium' | 'full' | 'host';
|
export type Effort = 'light' | 'medium' | 'full' | 'host';
|
||||||
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted';
|
export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted';
|
||||||
export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop';
|
export type Step = 'full' | 'scoped' | 'fix-check' | 'voices' | 'done' | 'stop';
|
||||||
|
|
||||||
export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const;
|
export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const;
|
||||||
export type Specialist = (typeof SPECIALISTS)[number];
|
export type Specialist = (typeof SPECIALISTS)[number];
|
||||||
@@ -338,6 +338,12 @@ export interface History {
|
|||||||
/** Runtime lines of the fix range (RANGE_BASE..HEAD) as classifyDiff counts them, 0 when it touches none; undefined or null when the range was not read. */
|
/** Runtime lines of the fix range (RANGE_BASE..HEAD) as classifyDiff counts them, 0 when it touches none; undefined or null when the range was not read. */
|
||||||
rangeRuntimeLines?: number | null;
|
rangeRuntimeLines?: number | null;
|
||||||
pass1Depth?: Depth;
|
pass1Depth?: Depth;
|
||||||
|
/** The diff is gate class (planReview sets it): Step 11's voices run before any stop. */
|
||||||
|
gate?: boolean;
|
||||||
|
/** Step 11's native adversarial pass already ran (planReview sets it from PlanInput.adversarialRan). */
|
||||||
|
adversarialRan?: boolean;
|
||||||
|
/** The approval id of the owner's recorded grant of one scoped fix-check after round 2 (review_loop_exception). */
|
||||||
|
granted?: string;
|
||||||
}
|
}
|
||||||
export interface NextStep { step: Step; reason: string }
|
export interface NextStep { step: Step; reason: string }
|
||||||
|
|
||||||
@@ -349,8 +355,19 @@ export interface NextStep { step: Step; reason: string }
|
|||||||
* changes no runtime lines; two rounds is the cap (R51: a scoped pass or a fix-check is
|
* changes no runtime lines; two rounds is the cap (R51: a scoped pass or a fix-check is
|
||||||
* one): after round 2, below-floor fixes finish with notes, and a blocker open or fixed
|
* 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.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function nextStep(h: History, depth?: Depth): NextStep {
|
export function nextStep(h: History, depth?: Depth): NextStep {
|
||||||
|
const n = loopStep(h, depth);
|
||||||
|
if (n.step !== 'stop') return n;
|
||||||
|
if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's voices (adversarial, outside, targeted) run before the owner's exit, then ask again with --adversarial-ran (${n.reason})` };
|
||||||
|
if (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` };
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loopStep(h: History, depth?: Depth): NextStep {
|
||||||
if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' };
|
if (h.last === 'none') return { step: 'full', reason: 'pass 1 reviews the whole diff' };
|
||||||
const fixed = !!(h.fixedBlocking || h.fixedBelow);
|
const fixed = !!(h.fixedBlocking || h.fixedBelow);
|
||||||
if (h.last === 'fix-check') return h.blockingOpen || h.fixedBlocking || h.touchedGate ? { step: 'stop', reason: `the fix-check ${h.touchedGate && !h.blockingOpen && !h.fixedBlocking ? 'edited a gate-class path' : 'found a blocking finding'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` } : { step: 'done', reason: fixed ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' };
|
if (h.last === 'fix-check') return h.blockingOpen || h.fixedBlocking || h.touchedGate ? { step: 'stop', reason: `the fix-check ${h.touchedGate && !h.blockingOpen && !h.fixedBlocking ? 'edited a gate-class path' : 'found a blocking finding'}: name it for the owner, whose grant may add one scoped fix-check, never a third round` } : { step: 'done', reason: fixed ? 'round cap 2 reached: the below-floor fixes ship as notes in the PR body and receipt' : 'the fix-check found nothing blocking' };
|
||||||
@@ -475,7 +492,7 @@ export function planReview(i: PlanInput): ReviewPlan {
|
|||||||
const c = classifyDiff(i);
|
const c = classifyDiff(i);
|
||||||
const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
|
const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor);
|
||||||
const { class: cls, runtimeLines } = depth;
|
const { class: cls, runtimeLines } = depth;
|
||||||
const next = nextStep(i.history, depth.depth);
|
const next = nextStep({ ...i.history, gate: cls === 'gate', adversarialRan: !!i.adversarialRan }, depth.depth);
|
||||||
const h = i.history;
|
const h = i.history;
|
||||||
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
|
const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound);
|
||||||
const stepDepth: Depth = raise ? 'standard' : depth.depth;
|
const stepDepth: Depth = raise ? 'standard' : depth.depth;
|
||||||
@@ -535,8 +552,11 @@ export function planReview(i: PlanInput): ReviewPlan {
|
|||||||
} else if (next.step === 'fix-check') {
|
} else if (next.step === 'fix-check') {
|
||||||
add('fix-check', 'fix-check', 'full');
|
add('fix-check', 'fix-check', 'full');
|
||||||
if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
|
if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host');
|
||||||
|
} else if (next.step === 'voices') {
|
||||||
|
add('adversarial-native', 'adversarial', 'full');
|
||||||
|
add('outside-voice', 'outside-voice', 'host');
|
||||||
}
|
}
|
||||||
if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check'].includes(next.step)) {
|
if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check', 'voices'].includes(next.step)) {
|
||||||
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
|
if (i.targeted.ran) skipped.push('targeted (already read this product patch)');
|
||||||
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
|
else add('targeted', 'targeted', ROLE_EFFORT.targeted);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.93.7",
|
"version": "1.93.8",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function newShip(f: Fixture, sc: Scenario): { calls: Call[]; ending: string } {
|
|||||||
const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
|
const diffBytes = plan.next.step === 'fix-check' ? f.diff_bytes * FIX_CHECK_FRACTION : h.fullPasses === 0 ? f.diff_bytes : f.diff_bytes * RANGE_FRACTION;
|
||||||
for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes });
|
for (const d of plan.dispatches) calls.push({ role: d.role, name: d.name, effort: d.effort, diffBytes: d.role === 'outside-voice' ? f.diff_bytes : diffBytes });
|
||||||
adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native');
|
adversarialRan ||= plan.dispatches.some(d => d.role === 'adversarial-native');
|
||||||
|
if (plan.next.step === 'voices') continue;
|
||||||
const o = outcomes[sc][Math.min(i, outcomes[sc].length - 1)] ?? {};
|
const o = outcomes[sc][Math.min(i, outcomes[sc].length - 1)] ?? {};
|
||||||
const last = plan.next.step === 'scoped' ? 'full' : plan.next.step;
|
const last = plan.next.step === 'scoped' ? 'full' : plan.next.step;
|
||||||
h = { fullPasses: h.fullPasses + (last === 'full' ? 1 : 0), last, ...o };
|
h = { fullPasses: h.fullPasses + (last === 'full' ? 1 : 0), last, ...o };
|
||||||
|
|||||||
+2
-3
@@ -84,11 +84,10 @@ reader ran.
|
|||||||
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
||||||
|
|
||||||
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
||||||
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
|
9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||||
`JUDGMENT: ok` before any fix), fixes and persistence in
|
|
||||||
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
||||||
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
||||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
|
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from
|
||||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
||||||
prints `ADVERSARIAL: skipped (review plan)`).
|
prints `ADVERSARIAL: skipped (review plan)`).
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -82,11 +82,10 @@ reader ran.
|
|||||||
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
## 4. Judgment: Steps 8.1, 8.2, 9.3, 9.4 and Step 11
|
||||||
|
|
||||||
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`) on the todos and commits files, then the core checklist, Steps
|
||||||
9.3, 9.4 and the repeat decision as written (`gstack-review-plan` included;
|
9.3, 9.4 and the repeat decision as written (`JUDGMENT: ok` before any fix), fixes and persistence in
|
||||||
`JUDGMENT: ok` before any fix), fixes and persistence in
|
|
||||||
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
one program. Prints `Pre-Landing Review:`, `Gate edits:`. `NEXT: stop` stops; `full` or
|
||||||
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
`scoped`: one program runs the failing test, fix, `--tests-only` and Phase 3, each gated (`diffBase` RANGE_BASE on `full` only; scoped fix-check: Step 3.5); `fix-check`: the
|
||||||
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done`: that program runs Step 11 from
|
pass with `fixCheck: {fix_list, hunks, tests, callers}`; `done` or `voices`: that program runs Step 11 from
|
||||||
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
`sections/adversarial.md` as written (above), only the voices the last full-pass plan lists (neither
|
||||||
prints `ADVERSARIAL: skipped (review plan)`).
|
prints `ADVERSARIAL: skipped (review plan)`).
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH
|
|||||||
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
|
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
|
||||||
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
||||||
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
||||||
|
After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither
|
||||||
|
decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as
|
||||||
|
`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands.
|
||||||
|
|
||||||
## Step 11: Adversarial review
|
## Step 11: Adversarial review
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH
|
|||||||
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
|
(`gstack-review-dispatch render --role targeted --classes <TARGETED classes> --base <base> --diff-base <merge base>`, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap,
|
||||||
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:<roles>`)
|
||||||
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
and add `--reservation <id>` to the `--start adversarial-review` call below.
|
||||||
|
After `NEXT: voices` (gate class: Step 11 before any stop), Finish the adversarial phase takes neither
|
||||||
|
decision 2 nor 3: rerun Step 9's repeat decision with `--adversarial-ran` (a queued blocking finding as
|
||||||
|
`--blocking-open`); its `NEXT:` (the stop, or the owner's granted fix-check) stands.
|
||||||
|
|
||||||
{{ADVERSARIAL_STEP}}
|
{{ADVERSARIAL_STEP}}
|
||||||
|
|
||||||
|
|||||||
@@ -820,8 +820,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
|
|||||||
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
||||||
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
||||||
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
||||||
`--depth-floor <d>` when given), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
|
||||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.
|
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||||
|
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
|
||||||
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
||||||
Other scopes apply decisions 2–4 in order.
|
Other scopes apply decisions 2–4 in order.
|
||||||
|
|
||||||
|
|||||||
@@ -229,8 +229,9 @@ Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan -
|
|||||||
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`,
|
||||||
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
`--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass
|
||||||
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers <fp>`,
|
||||||
`--depth-floor <d>` when given), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
`--depth-floor <d>` when given, `--grant <approval id>` once the owner's grant of one scoped fix-check is
|
||||||
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.
|
in `gstack-approvals` naming `review_loop_grant` and the stop head), `--range-base $PASS_HEAD` and `--pass1-depth <pass 1's REVIEW_DEPTH>`;
|
||||||
|
its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. `voices` 4: Step 11 runs before the stop.
|
||||||
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
`scoped` is 3 too. A full pass 1 always gets a full pass 2.
|
||||||
Other scopes apply decisions 2–4 in order.
|
Other scopes apply decisions 2–4 in order.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
|
import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
|
||||||
import { summarizeReviewLoop } from '../lib/review-scope';
|
import { summarizeReviewLoop } from '../lib/review-scope';
|
||||||
|
import { sectionTexts } from '../lib/review-brief';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
const ROOT = path.join(import.meta.dir, '..');
|
const ROOT = path.join(import.meta.dir, '..');
|
||||||
const none = scanNumbers({});
|
const none = scanNumbers({});
|
||||||
@@ -70,6 +72,123 @@ describe('nextStep: the transition table', () => {
|
|||||||
test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done'));
|
test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Value: protects=on gate class Step 11's adversarial, outside and targeted voices run before any owner exit the round cap reaches, and a recorded owner grant dispatches its one scoped fix-check after round 2, once; fails_when=a gate-class round-2 blocker fix stops before Step 11 (C6-36 after-opus: next=stop, no voice ran), or a recorded grant still stops (C6-36 after-opus-grant: the lane hand-dispatched the fix-check);
|
||||||
|
// why_new=GST-967; seam=nextStep, planReview; no_claim=the voices' or the fix-check's verdicts
|
||||||
|
describe('GST-967: the round cap limits fix rounds, not the gate-class voices; a recorded grant runs its fix-check', () => {
|
||||||
|
const capStop = { fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true } as const;
|
||||||
|
test('a gate-class round-2 blocker fix runs Step 11\'s voices before the owner stop, then stops', () => {
|
||||||
|
expect(nextStep({ ...capStop, gate: true })).toMatchObject({ step: 'voices', reason: expect.stringContaining("Step 11's voices") });
|
||||||
|
for (const o of [{ blockingOpen: true }, { fixedBlocking: false, fixedBelow: true, touchedGate: true }, { fullPasses: 1, last: 'fix-check' as const }, { fullPasses: 1, blockingOpen: true }]) expect(nextStep({ ...capStop, gate: true, ...o }).step).toBe('voices');
|
||||||
|
expect(nextStep({ ...capStop, gate: true, adversarialRan: true }).step).toBe('stop');
|
||||||
|
expect(nextStep(capStop).step).toBe('stop');
|
||||||
|
});
|
||||||
|
test('C6-36 run 1 replanned: the plan lists Step 11\'s three voices, and once they ran the stop stands', () => {
|
||||||
|
const h = { ...capStop, touchedGate: true };
|
||||||
|
const p = planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, history: h }));
|
||||||
|
expect([p.class, p.next.step, roles(p)]).toEqual(['gate', 'voices', ['adversarial-native:adversarial:full', 'outside-voice:outside-voice:host', 'targeted:targeted:full']]);
|
||||||
|
expect(planReview(base({ gateTagged: 1, adversarialRan: true, targeted: { classes: ['gate'] }, history: h })).next.step).toBe('stop');
|
||||||
|
});
|
||||||
|
test('a recorded grant dispatches one scoped fix-check after a round-2 full pass, after the gate-class voices, and never a second', () => {
|
||||||
|
const g = { ...capStop, granted: 'a-feb4aba31633' };
|
||||||
|
expect(nextStep(g)).toMatchObject({ step: 'fix-check', reason: expect.stringContaining('a-feb4aba31633') });
|
||||||
|
expect(roles(planReview(base({ history: g })))).toEqual(['fix-check:fix-check:full']);
|
||||||
|
expect(nextStep({ ...g, gate: true }).step).toBe('voices');
|
||||||
|
expect(nextStep({ ...g, gate: true, adversarialRan: true }).step).toBe('fix-check');
|
||||||
|
expect(nextStep({ ...g, fixedBlocking: false, fixedBelow: true, touchedGate: true }).step).toBe('fix-check');
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBlocking: true, granted: 'a-1' }).step).toBe('stop');
|
||||||
|
expect(nextStep({ ...g, blockingOpen: true }).step).toBe('stop');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
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'));
|
||||||
|
expect(table).toContain("| 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` |");
|
||||||
|
expect(table).toContain('| 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 |');
|
||||||
|
const rule3 = doc.slice(doc.indexOf('3. **Two rounds is the cap**'), doc.indexOf('<a id="transitions"></a>')).replace(/\s+/g, ' ');
|
||||||
|
expect(rule3).toContain("The cap limits fix rounds, not Step 11's voices");
|
||||||
|
expect(rule3).toContain('`gstack-review-plan --grant <approval id> --range-base <round 2\'s PASS_HEAD>`');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GST-967's safety property, as a permanent invariant over the transitions table (Captain, 2026-11-02: the invariant is the
|
||||||
|
* gate, a reader the second check). Every state: passes 0-3, each last step, every mix of the four fix flags, gate class or
|
||||||
|
* not, Step 11 run or not, grant recorded or not.
|
||||||
|
*/
|
||||||
|
describe('GST-967 invariant: no gate-class owner exit before Step 11\'s voices; a recorded grant always gets its fix-check', () => {
|
||||||
|
// Value: protects=a gate-class plan never stops (the exit where the owner may ship with open notes) before Step 11's adversarial, outside and targeted voices ran, voices is planned only then and dispatches all three, and a recorded grant always turns the stop after a round-2 full pass with a fixed finding into exactly one fix-check while changing nothing else, so it never repeats; fails_when=any state breaks one of those, so a later edit to nextStep cannot fail open;
|
||||||
|
// why_new=GST-967 (three earlier fixes failed open and a reviewer caught each after the fact); seam=nextStep, planReview; no_claim=the parent following NEXT
|
||||||
|
test('every state of the table holds the property', () => {
|
||||||
|
let states = 0, voices = 0, grants = 0;
|
||||||
|
for (const fullPasses of [0, 1, 2, 3]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++)
|
||||||
|
for (const gate of [false, true]) for (const adversarialRan of [false, true]) {
|
||||||
|
const h = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), gate, adversarialRan };
|
||||||
|
const plain = nextStep(h), granted = nextStep({ ...h, granted: 'a-1' });
|
||||||
|
const ungatedStop = nextStep({ ...h, gate: false }).step === 'stop';
|
||||||
|
states++;
|
||||||
|
for (const n of [plain, granted]) {
|
||||||
|
if (gate && !adversarialRan) expect(n.step).not.toBe('stop');
|
||||||
|
if (n.step === 'voices') expect([gate, adversarialRan, ungatedStop]).toEqual([true, false, true]);
|
||||||
|
}
|
||||||
|
if (plain.step === 'voices') voices++;
|
||||||
|
const owed = ungatedStop && !h.blockingOpen && last === 'full' && fullPasses === FULL_PASS_CAP && (!gate || adversarialRan);
|
||||||
|
if (owed) grants++;
|
||||||
|
expect([granted.step === 'fix-check' && plain.step !== 'fix-check', granted.step === plain.step]).toEqual([owed, !owed]);
|
||||||
|
if (owed) expect(granted.reason).toContain('a-1');
|
||||||
|
}
|
||||||
|
expect(states).toBe(4 * 3 * 16 * 2 * 2);
|
||||||
|
expect([voices > 0, grants > 0]).toEqual([true, true]);
|
||||||
|
const gatePlan = (o: Partial<PlanInput>) => planReview(base({ gateTagged: 1, targeted: { classes: ['gate'] }, ...o }));
|
||||||
|
for (const fullPasses of [1, 2]) for (const last of ['full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) {
|
||||||
|
const p = gatePlan({ history: { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8), granted: bits & 1 ? 'a-1' : undefined } });
|
||||||
|
expect(p.next.step).not.toBe('stop');
|
||||||
|
if (p.next.step === 'voices') expect(p.dispatches.map(d => d.role)).toEqual(['adversarial-native', 'outside-voice', 'targeted']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GST-967 dispatch-superset no-loss check (crew rule 2026-10-30; replay 'GST-967' in test/review-packets-removed.test.ts).
|
||||||
|
* On a 1,440-case grid (five diffs: light, standard, gate, docs, tests; passes 0-2; last none, full or fix-check; every mix of
|
||||||
|
* the four fix flags; Step 11 run or not), main's planReview at 1022f4f (v1.92.81.0) hashes to MAIN_PLANS. The branch's plan,
|
||||||
|
* with each voices step read back as main's stop (its reason is the parenthesised tail, no dispatch), hashes to the same value,
|
||||||
|
* and every voices step is a gate-class stop before Step 11 that adds exactly Step 11's three voices. No grant is passed (main
|
||||||
|
* takes none). The brief bodies lib/review-brief.ts cuts from the ship sections hash to MAIN_SECTIONS, main's value.
|
||||||
|
*/
|
||||||
|
const MAIN_PLANS = '9d13d6bae86bfc9207ce7e0cec8da5b33db2518c1653e8e009160a98b0f04c89';
|
||||||
|
const MAIN_SECTIONS = 'a2b999aa2de2c4cdbb953aeb0a4fac6a67e01a2c5d74121e191b95781e68e345';
|
||||||
|
describe('GST-967 dispatch-superset no-loss check', () => {
|
||||||
|
// Value: protects=the no-loss rule for review-depth changes: GST-967 drops, renames and re-efforts no reviewer main dispatches on any grid case, and changes no brief body; fails_when=a non-stop plan changes, a voices step appears off gate class or after Step 11, it dispatches anything but the three voices, or a brief body moves;
|
||||||
|
// why_new=GST-967; seam=planReview, sectionTexts; no_claim=what the added voices find (they only add coverage)
|
||||||
|
test('the grid matches main except gate-class stops before Step 11, which add only the three voices; brief bodies are main\'s', () => {
|
||||||
|
const sha = (x: unknown) => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
||||||
|
const fixtures: Record<string, Partial<PlanInput>> = {
|
||||||
|
light: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120 },
|
||||||
|
standard: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, floor: 'standard' },
|
||||||
|
gate: { paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true' }, gateTagged: 1, diffLines: 120, targeted: { classes: ['gate'] } },
|
||||||
|
docs: { paths: ['README.md'], scope: {}, gateTagged: null, diffLines: 30 },
|
||||||
|
tests: { paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' }, gateTagged: 0, diffLines: 40 },
|
||||||
|
};
|
||||||
|
const rows: unknown[] = [];
|
||||||
|
let voices = 0;
|
||||||
|
for (const [name, f] of Object.entries(fixtures)) for (const fullPasses of [0, 1, 2]) for (const last of ['none', 'full', 'fix-check'] as const) for (let bits = 0; bits < 16; bits++) for (const adversarialRan of [false, true]) {
|
||||||
|
const history = { fullPasses, last, fixedBlocking: !!(bits & 1), fixedBelow: !!(bits & 2), blockingOpen: !!(bits & 4), touchedGate: !!(bits & 8) };
|
||||||
|
const p = planReview({ ...f, numbers: none, history, adversarialRan } as PlanInput);
|
||||||
|
let { step, reason } = p.next;
|
||||||
|
let dispatches = p.dispatches;
|
||||||
|
if (step === 'voices') {
|
||||||
|
voices++;
|
||||||
|
expect([p.class, adversarialRan, dispatches.map(d => `${d.role}:${d.effort}`)]).toEqual(['gate', false, ['adversarial-native:full', 'outside-voice:host', 'targeted:full']]);
|
||||||
|
[step, reason, dispatches] = ['stop', reason.slice(reason.indexOf('--adversarial-ran (') + '--adversarial-ran ('.length, -1), []];
|
||||||
|
}
|
||||||
|
rows.push({ name, fullPasses, last, bits, adversarialRan, class: p.class, step, reason, dispatches, skipped: p.skipped });
|
||||||
|
}
|
||||||
|
expect([rows.length, voices, sha(rows)]).toEqual([1440, 71, MAIN_PLANS]);
|
||||||
|
expect(sha(sectionTexts(ROOT))).toBe(MAIN_SECTIONS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('scanNumbers', () => {
|
describe('scanNumbers', () => {
|
||||||
test('finds claims and fingerprints them', () => {
|
test('finds claims and fingerprints them', () => {
|
||||||
const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] });
|
const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] });
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ const NO_LOSS_REPLAYS = {
|
|||||||
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
|
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
|
||||||
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
|
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
|
||||||
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
|
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
|
||||||
|
'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged",
|
||||||
} as const;
|
} as const;
|
||||||
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
type Replay = keyof typeof NO_LOSS_REPLAYS;
|
||||||
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||||
'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' },
|
'ship/sections/review-army.md': { sha: '7c58dfd1bf1f16f967186a4872191cb16ebff3be81a50ebbf19067020af3ec53', replay: 'GST-967' },
|
||||||
'ship/sections/adversarial.md': { sha: 'd776ee5bc6e0c13930f8f4de05bad2f1ed9e52bc9fc5df80ae3ba7680455b2bb', replay: 'GST-257' },
|
'ship/sections/adversarial.md': { sha: '5d712bb568573931108a79b1bd40b74db6189610f47c547099c13098ef67bef8', replay: 'GST-967' },
|
||||||
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
|
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
|
||||||
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
|
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
|
||||||
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
|
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
|
||||||
@@ -82,7 +83,7 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
|||||||
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
|
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
|
||||||
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
|
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
|
||||||
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
|
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
|
||||||
'lib/review-depth.ts': { sha: '166dd7b1148a272b345dd9d2ceccd14348cc59c52ef9c88109436f6f61cf6b69', replay: 'GST-911-redos' },
|
'lib/review-depth.ts': { sha: 'c5fd958b6c9a1116be9fc3418844239c01168476abb72c1db85bc26ab0adf4b4', replay: 'GST-967' },
|
||||||
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' },
|
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -740,6 +740,26 @@ describe('Capy pass program (fake host, real bins)', () => {
|
|||||||
expect(h.creates.find(c => c.title === 'ship x21p2 red-team')!.prompt).toContain('DIFF_BASE=' + rangeBase);
|
expect(h.creates.find(c => c.title === 'ship x21p2 red-team')!.prompt).toContain('DIFF_BASE=' + rangeBase);
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
|
||||||
|
// Value: protects=the pass program dispatches the owner's granted fix-check from the plan, as decision 3.5 runs any fix-check, instead of PASS: none; fails_when=a recorded grant still prints PASS: none next=stop and the lane renders, sends and collects the fix-check by hand (C6-36 after-opus-grant);
|
||||||
|
// why_new=GST-967; seam=the pass program; no_claim=the fix-check reviewer's verdict
|
||||||
|
test('GST-967: a recorded grant after round 2 runs its one scoped fix-check through the program', async () => {
|
||||||
|
const { dir, env } = repo();
|
||||||
|
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||||
|
const add = spawnSync(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${head.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: dir, env: { ...env, GSTACK_APPROVALS_MIRROR: '0' }, encoding: 'utf8', timeout: 60_000 });
|
||||||
|
expect(add.status, add.stderr).toBe(0);
|
||||||
|
const id = JSON.parse(add.stdout).added[0] as string;
|
||||||
|
const files = path.join(dir, '.state/gfc');
|
||||||
|
fs.mkdirSync(files, { recursive: true });
|
||||||
|
const fc: Record<string, string> = {};
|
||||||
|
for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n'], ['callers', 'none\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
|
||||||
|
const h = host(dir, env);
|
||||||
|
const res = await runPass(h, { base: 'main', out: path.join(dir, '.state/run/gfc'), pass: 'x30gfc', planFlags: ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', head, '--grant', id], fixCheck: fc, logStart: ['review-fix-check'] });
|
||||||
|
expect(res).not.toContain('PASS: none');
|
||||||
|
expect(res).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||||
|
expect(h.creates.map(c => c.title)).toEqual(['ship x30gfc fix-check']);
|
||||||
|
expect(res).toContain('NEXT: judge (Phase 4)');
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
// Value: protects=the Capy pass program runs a scoped repeat with no fix-check files, dispatches its Red Team and leaves the fix-check to Step 3.5 as GST-619 does on the CLI; fails_when=the program stops a scoped plan for missing files or creates a fix-check reader in the scoped manifest;
|
// Value: protects=the Capy pass program runs a scoped repeat with no fix-check files, dispatches its Red Team and leaves the fix-check to Step 3.5 as GST-619 does on the CLI; fails_when=the program stops a scoped plan for missing files or creates a fix-check reader in the scoped manifest;
|
||||||
// why_new=#3670 Step 11 (adversarial 1, red team 1), re-scoped to GST-619's model in the merge of main; seam=none; no_claim=the Step 3.5 fix-check itself
|
// why_new=#3670 Step 11 (adversarial 1, red team 1), re-scoped to GST-619's model in the merge of main; seam=none; no_claim=the Step 3.5 fix-check itself
|
||||||
test('a scoped repeat runs without fixCheck files and leaves its fix-check to Step 3.5 (GST-619)', async () => {
|
test('a scoped repeat runs without fixCheck files and leaves its fix-check to Step 3.5 (GST-619)', async () => {
|
||||||
|
|||||||
@@ -62,6 +62,43 @@ describe('gstack-review-plan', () => {
|
|||||||
expect(r.stdout).not.toContain('NEXT: stop');
|
expect(r.stdout).not.toContain('NEXT: stop');
|
||||||
expect(run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below', '--blocking-open']).stdout).toContain('NEXT: stop');
|
expect(run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'fix-check', '--fixed-below', '--blocking-open']).stdout).toContain('NEXT: stop');
|
||||||
});
|
});
|
||||||
|
// Value: protects=a grant the owner recorded with gstack-approvals is something the plan can use, once, in the invocation it answers: --grant <id> plans the one scoped fix-check after a round-2 full pass when the row names review_loop_grant and the stop head (at or after --range-base, at or before HEAD); the fix-check never earns another; an unrecorded id, an unrelated approval, a review_loop_exception acceptance row, or a grant for an earlier head is refused; fails_when=the CLI ignores the grant and prints NEXT: stop (C6-36 after-opus-grant), accepts any of those rows, or grants a second fix-check;
|
||||||
|
// why_new=GST-967 (round-2 red team: a grant from an earlier /ship on the branch could be reused); seam=bin/gstack-review-plan; no_claim=who may grant (the owner's words are the approval record's)
|
||||||
|
test('GST-967: --grant <id> plans the one scoped fix-check for the stop head it names; any other row, or a grant for an earlier head, is refused', () => {
|
||||||
|
const r = repo({ 'src/a.ts': 'export const a = 1;\n' });
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: r.state, GSTACK_APPROVALS_MIRROR: '0' };
|
||||||
|
delete env.GSTACK_APPROVALS_DIR;
|
||||||
|
const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||||
|
const commit = (n: number) => { fs.appendFileSync(path.join(r.dir, 'src/a.ts'), `export const v${n} = ${n};\n`); git('commit', '-qam', `c${n}`); return git('rev-parse', 'HEAD'); };
|
||||||
|
const stale = git('rev-parse', 'HEAD');
|
||||||
|
const passHead = commit(1);
|
||||||
|
const stopHead = commit(2);
|
||||||
|
const add = (ask: string) => {
|
||||||
|
const a = spawnSync(path.join(path.dirname(BIN), 'gstack-approvals'), ['add', '--ask', ask, '--words', 'do it', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env });
|
||||||
|
expect(a.status, a.stderr).toBe(0);
|
||||||
|
return JSON.parse(a.stdout).added[0] as string;
|
||||||
|
};
|
||||||
|
const id = add(`Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`);
|
||||||
|
const refusedIds = [add(`Owner grant from an earlier run (review_loop_grant ${stale.slice(0, 12)})`), add('Owner grant: one scoped fix-check (review_loop_grant)'),
|
||||||
|
add(`Owner accepts the stop after the fix-check (review_loop_exception ${stopHead.slice(0, 12)})`), add('Approve the plan for the README rewrite'), 'a-000000000000'];
|
||||||
|
const plan = (a: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', ...a], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env });
|
||||||
|
const capStop = ['--pass', '2', '--last', 'full', '--fixed-blocking', '--range-base', passHead];
|
||||||
|
expect(plan(capStop).stdout).toContain('NEXT: stop');
|
||||||
|
const granted = plan([...capStop, '--grant', id]);
|
||||||
|
expect(granted.status, granted.stderr).toBe(0);
|
||||||
|
expect(granted.stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||||
|
expect(granted.stdout).toContain('DISPATCH: fix-check fix-check effort=full');
|
||||||
|
commit(3);
|
||||||
|
expect(plan([...capStop, '--grant', id]).stdout).toContain(`NEXT: fix-check (the owner's grant ${id}`);
|
||||||
|
expect(plan(['--pass', '2', '--last', 'fix-check', '--fixed-blocking', '--range-base', passHead, '--grant', id]).stdout).toContain('NEXT: stop');
|
||||||
|
for (const other of refusedIds) {
|
||||||
|
const refused = plan([...capStop, '--grant', other]);
|
||||||
|
expect([other, refused.status, refused.stdout]).toEqual([other, 3, '']);
|
||||||
|
expect(refused.stderr).toContain(`--grant ${other}`);
|
||||||
|
}
|
||||||
|
expect(plan([...capStop.slice(0, -2), '--grant', id]).status).toBe(2);
|
||||||
|
expect(plan([...capStop, '--grant', '--json']).status).toBe(2);
|
||||||
|
});
|
||||||
test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => {
|
test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => {
|
||||||
const r = repo({ 'docs/howto.md': '# x\n' });
|
const r = repo({ 'docs/howto.md': '# x\n' });
|
||||||
const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; };
|
const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; };
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"bytes": 476,
|
||||||
|
"why": "GST-967: Step 11 voices before any gate-class stop (NEXT: voices) and the owner's recorded grant (--grant) in the repeat decision and Step 11"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user