mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
v1.92.45.0 /ship review scales to risk, with a 2-round cap (R51) (#3820)
* review: /ship depth scales to risk, not size; two rounds is the cap (R51, GSTA-167) Non-risky code is light at any size; a risk class (money, auth, secret, security, user data) or gate code makes it full. UI or game text counts as docs. A fix-check counts as a round and round 2 never earns a third. The receipt records review_loop.rounds and review_depth.risk. Repins lib/review-depth.ts and pins lib/review-risk.ts under no-loss replay R51 (test/review-packets-removed.test.ts): the saved panel findings re-scored against this plan lose no blocker, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (round 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: UI text and SVGs stay code; a round-2 gate edit stops for the owner; SQL deletes match in any case (R51, Captain choice A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore review-policy.md tail; keep uppercase DELETE FROM (R51 repair, Captain grant) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: R51 fragment matches the final change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy review-loop cap says two rounds (Step 14.5 audit) 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
4d388f44bd
commit
589ff24286
31 files changed
+430
-147
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.92.45.0] - 2026-11-01
|
||||||
|
|
||||||
|
### /ship review scales to risk, not size
|
||||||
|
|
||||||
|
- **Review depth follows risk.** Code that touches security, user data, money, secrets, auth or the merge checks gets the full review panel. Other code goes to the light tier at any size, with one batched reader plus the native adversarial pass. Docs, tests and eval write-ups get one reader. UI text and SVG files stay code for now. A larger diff never raises the depth by itself.
|
||||||
|
- **New risk classes.** `lib/review-risk.ts` holds the money, auth and secret matchers the targeted pass already used, plus new security (trust boundaries, shell and HTML sinks, shells run through argv, recursive deletes, `eval`) and user-data (persistence paths, `DELETE FROM`, `deleteMany`, `destroy_all`) signals.
|
||||||
|
- **At most 2 review rounds.** A fix-check counts as a round. A blocker that is still open or fixed in round 2 stops for the owner, who may grant one scoped fix-check; there is no round 3. A fix in round 2 that edits gate code also stops for the owner. Below-floor fixes in round 2 finish, with the notes listed.
|
||||||
|
- **Receipts record tier and rounds.** `review_loop.rounds` and `review_depth.risk` are on the receipt, and the summary line prints `rounds=`. `gstack-ship-queue ready` still refuses a receipt below its head's depth.
|
||||||
|
- Replayed on the 26-unit no-loss panel, no historical blocker was lost. Over the last 100 main commits (an estimate from replaying the depth rules), 8 go from standard to light, 12 go up to full, and no commit with a risk class goes down.
|
||||||
|
|
||||||
## [1.92.44.0] - 2026-11-01
|
## [1.92.44.0] - 2026-11-01
|
||||||
|
|
||||||
**`literals` stops treating a same-name sibling file as the changed module (GST-820).** A test that only mentioned `test/postgres-unit-arms.txt` was counted as a consumer of `scripts/postgres-unit-arms.ts`, so any `12` in it failed `PREGATE_LITERALS` when `ARM_SHARDS` moved from 12 to 18. In gbrain #8372 that gave 17 false files. A path now names the module only when its stem is followed by the module's own extension (for a JS/TS module, any JS/TS extension), or by anything other than a letter, digit, `_`, `-` or another extension, and the full file name no longer matches with a further extension such as `.snap`. Extensionless imports, `.js` imports and the module's own path still count.
|
**`literals` stops treating a same-name sibling file as the changed module (GST-820).** A test that only mentioned `test/postgres-unit-arms.txt` was counted as a consumer of `scripts/postgres-unit-arms.ts`, so any `12` in it failed `PREGATE_LITERALS` when `ARM_SHARDS` moved from 12 to 18. In gbrain #8372 that gave 17 false files. A path now names the module only when its stem is followed by the module's own extension (for a JS/TS module, any JS/TS extension), or by anything other than a letter, digit, `_`, `-` or another extension, and the full file name no longer matches with a further extension such as `.snap`. Extensionless imports, `.js` imports and the module's own path still count.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.92.44.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.92.45.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,
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
|
|||||||
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
|
||||||
--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: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money,
|
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,
|
||||||
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, 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).
|
||||||
|
|||||||
+43
-16
@@ -93,11 +93,14 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
|||||||
for every reviewer (the gate scan and QA probes keep the whole candidate) and
|
for every reviewer (the gate scan and QA probes keep the whole candidate) and
|
||||||
classifies with `--range-base`. Only a blocking finding in that range or in
|
classifies with `--range-base`. Only a blocking finding in that range or in
|
||||||
the diff is `fix`; every below-floor finding is a `note`.
|
the diff is `fix`; every below-floor finding is a `note`.
|
||||||
3. **Two full passes is the cap**, plus at most one fix-check; a scoped pass
|
3. **Two rounds is the cap** (R51): a full pass, a scoped pass
|
||||||
([below](#scoped-pass)) is a full pass. A blocking
|
([below](#scoped-pass)) or a fix-check is a round. After round 2 the run
|
||||||
finding still open after the last allowed step stops the run and names the
|
finishes with its open notes listed in the PR body and receipt, or stops for
|
||||||
finding (path, severity, problem) for the owner; a third full pass is the
|
the owner when a blocking finding is open, or was fixed in round 2 and is
|
||||||
owner's call, never the loop's. The cap approves nothing.
|
unverified, or a round-2 fix edited a gate-class path, naming it (path,
|
||||||
|
severity, problem). A third round is the owner's
|
||||||
|
grant of one scoped fix-check (recorded in `review_loop_exception`), never the
|
||||||
|
loop's. The cap approves nothing.
|
||||||
|
|
||||||
<a id="transitions"></a>
|
<a id="transitions"></a>
|
||||||
### Transitions
|
### Transitions
|
||||||
@@ -112,10 +115,12 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
|||||||
| a full pass that fixed a blocking finding, below the cap, on a diff below full depth with a fix range of at most 150 runtime lines | scoped pass on the range |
|
| a full pass that fixed a blocking finding, below the cap, on a diff below full depth with a fix range of at most 150 runtime lines | scoped pass on the range |
|
||||||
| a full pass that fixed a blocking finding, below the cap (full depth, a larger range, or a range or depth it could not read) | full pass on the range |
|
| a full pass that fixed a blocking finding, below the cap (full depth, a larger range, or a range or depth it could not read) | full pass on the range |
|
||||||
| a full pass whose fix touched a gate-class path, below the cap | full pass on the range |
|
| a full pass whose fix touched a gate-class path, below the cap | full pass on the range |
|
||||||
| a full pass that fixed only below-floor findings | one fix-check |
|
| a full pass that fixed only below-floor findings | one fix-check (round 2) |
|
||||||
| a full pass at the cap that fixed a blocking finding | one fix-check |
|
| a light pass 1 whose below-floor fixes change no runtime lines | done |
|
||||||
|
| round 2 (a full pass or a fix-check) that fixed a blocking finding or edited a gate-class path | stop: name it for the owner, who may grant one scoped fix-check |
|
||||||
|
| round 2 (a full pass or a fix-check) that fixed only below-floor findings | done: notes in the PR body |
|
||||||
| 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 (below-floor fixes before it included) | 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 **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
|
||||||
@@ -186,10 +191,27 @@ in a data file still schedules the outside voice.
|
|||||||
|
|
||||||
| Depth | When | Pass 1 dispatches |
|
| Depth | When | Pass 1 dispatches |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers |
|
| **light**, runtime 0 | docs, tests, eval write-ups, or only registered outputs | one reader: the core checklist; the outside voice instead reads a diff that publishes numbers |
|
||||||
| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (the batched checklists below plus the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers |
|
| **light**, runtime above 0 | code with no risk class, at any size (R51: size never raises the depth) | core, one **batched** specialist (the batched checklists below plus the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers |
|
||||||
| **standard** | more than 50 runtime lines | one batched specialist at `medium` and the Red Team at 200 runtime lines (or on a CRITICAL); the batched reader leaves docs and registered outputs out of its diff; adversarial and outside voice read everything |
|
| **standard** | `--depth-floor standard`, or a light diff raised by a fixed blocker or a CRITICAL | one batched specialist at `medium` and the Red Team at 200 runtime lines (or on a CRITICAL or a raise); the batched reader leaves docs and registered outputs out of its diff; adversarial and outside voice read everything |
|
||||||
| **full** | gate class, or any classifier error | one batched specialist on the whole diff, the Red Team always, no hit-rate gating |
|
| **full** | gate class, a [risk class](#risk-classes), or any classifier error | one batched specialist on the whole diff, the Red Team always, no hit-rate gating |
|
||||||
|
|
||||||
|
<a id="risk-classes"></a>
|
||||||
|
**Risk classes (R51).** `lib/review-risk.ts` reads a code diff's paths and its
|
||||||
|
added and deleted lines; any class makes the depth full (`risk:<class>` in
|
||||||
|
`reasons=`, `review_depth.risk` in the receipt). money, auth and secret are the
|
||||||
|
[targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code)'s matchers.
|
||||||
|
**security** is a trust-boundary path (trust, redact, sanitize, neutralize,
|
||||||
|
consent, escape) or line (`shell: true`, `execSync(`, `eval`, `new Function(`,
|
||||||
|
raw HTML sinks, a shell run through argv, recursive deletes). **data** is user data: a path naming pii, gdpr, ccpa, privacy,
|
||||||
|
personal, account, customer, cookie, persistence, storage or database, or a line
|
||||||
|
that drops a table or column, deletes rows or bulk-deletes records. Auth scope,
|
||||||
|
migrations and deleted code paths were already gate class. `RISK_RULES` hashes
|
||||||
|
the patterns and `DEPTH_RULES` includes it.
|
||||||
|
|
||||||
|
**UI text and SVGs** stay code (light unless a risk class applies). A docs path
|
||||||
|
for UI copy, locale values and chart SVGs waits for an allowlist classifier: three
|
||||||
|
rounds of R51 review found bypasses in line-pattern versions of both.
|
||||||
|
|
||||||
**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist
|
**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist
|
||||||
subagent reads Testing and Security on every code or gate diff, plus
|
subagent reads Testing and Security on every code or gate diff, plus
|
||||||
@@ -230,7 +252,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b
|
|||||||
|
|
||||||
**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`,
|
**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`,
|
||||||
`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`,
|
`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`,
|
||||||
`lib/review-targeted.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
|
`lib/review-targeted.ts`, `lib/review-risk.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
|
||||||
`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or
|
`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or
|
||||||
`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets
|
`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets
|
||||||
the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns,
|
the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns,
|
||||||
@@ -282,6 +304,10 @@ the specialists, audits and watchers at the host default too
|
|||||||
prompt. At runtime 0 light runs no adversarial pass either.
|
prompt. At runtime 0 light runs no adversarial pass either.
|
||||||
- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy
|
- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy
|
||||||
diff with few runtime lines dispatches fewer specialists.
|
diff with few runtime lines dispatches fewer specialists.
|
||||||
|
- R51: size never raises the depth; non-risky code is light at any size, and a risk
|
||||||
|
class makes it full. Two rounds is the cap, a fix-check
|
||||||
|
included: a blocker fixed in round 2 stops for the owner instead of earning a
|
||||||
|
fix-check, and a light pass 1 whose below-floor fixes change no runtime lines is done.
|
||||||
- A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167
|
- A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167
|
||||||
part 4a). A binary image no code names is no longer a `binary_code_path` error
|
part 4a). A binary image no code names is no longer a `binary_code_path` error
|
||||||
that forces full depth; one that code names still is.
|
that forces full depth; one that code names still is.
|
||||||
@@ -354,6 +380,7 @@ puts in the receipt:
|
|||||||
| `review_loop.fixed` | findings fixed in this PR |
|
| `review_loop.fixed` | findings fixed in this PR |
|
||||||
| `review_loop.scope` | the `review_scope` the loop ran under |
|
| `review_loop.scope` | the `review_scope` the loop ran under |
|
||||||
| `review_loop.fix_checks` | fix-checks this ship ran (never passes) |
|
| `review_loop.fix_checks` | fix-checks this ship ran (never passes) |
|
||||||
|
| `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.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_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 output collected and the committed diff it was rendered on still the one at verify, else `failed`; absent when write got no record, which the queue reports as `legacy`), `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 output collected and the committed diff it was rendered on still the one at verify, else `failed`; absent when write got no record, which the queue reports as `legacy`), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged |
|
||||||
@@ -366,7 +393,7 @@ specialist stats they record). The summary line appends `fix_checks=`,
|
|||||||
loop prints the old line.
|
loop prints the old line.
|
||||||
|
|
||||||
Beside the counters, `write` records `review_depth` (the depth, runtime lines,
|
Beside the counters, `write` records `review_depth` (the depth, runtime lines,
|
||||||
rules hash, base and registry commits, and the gate scan bound to the head) and
|
rules hash, base and registry commits, the gate scan bound to the head and the risk classes) and
|
||||||
the queue refuses a receipt below the depth its head needs
|
the queue refuses a receipt below the depth its head needs
|
||||||
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
|
([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner
|
||||||
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
|
accepted after a fix-check edit goes in `review_loop_exception`, never in prose
|
||||||
@@ -385,8 +412,8 @@ difference passed a scoped review.
|
|||||||
|
|
||||||
| Value | Pass 1 | Blocking finding in unchanged code | Pass 2+ | Cap |
|
| Value | Pass 1 | Blocking finding in unchanged code | Pass 2+ | Cap |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `frozen` (default) | the diff | filed as an issue, listed as deferred | the range since the previous pass, blocking only | 2 full passes + 1 fix-check |
|
| `frozen` (default) | the diff | filed as an issue, listed as deferred | the range since the previous pass, blocking only | 2 rounds (a fix-check is one) |
|
||||||
| `diff-plus-blocking` | the diff | fixed in this PR | the range since the previous pass, blocking only | 2 full passes + 1 fix-check |
|
| `diff-plus-blocking` | the diff | fixed in this PR | the range since the previous pass, blocking only | 2 rounds (a fix-check is one) |
|
||||||
| `all` | everything | fixed in this PR | the whole diff, every finding | 3 fix cycles |
|
| `all` | everything | fixed in this PR | the whole diff, every finding | 3 fix cycles |
|
||||||
|
|
||||||
`diff-plus-blocking` is the fallback for a repository where deferred issues pile
|
`diff-plus-blocking` is the fallback for a repository where deferred issues pile
|
||||||
|
|||||||
+4
-4
@@ -1221,13 +1221,13 @@ construction:
|
|||||||
fix-range`) or in the diff is `scope: fix`; nothing below the floor earns a
|
fix-range`) or in the diff is `scope: fix`; nothing below the floor earns a
|
||||||
pass. `NO FINDINGS` is the expected reviewer result for a clean range, never a
|
pass. `NO FINDINGS` is the expected reviewer result for a clean range, never a
|
||||||
failed review.
|
failed review.
|
||||||
3. **Two full passes plus one fix-check is the cap**, stepped by the
|
3. **Two rounds is the cap** (R51), stepped by the
|
||||||
[transition table](review-policy.md#transitions); a
|
[transition table](review-policy.md#transitions); a full pass, a
|
||||||
[scoped pass](review-policy.md#scoped-pass) counts as a full pass. A pass that applied no edits
|
[scoped pass](review-policy.md#scoped-pass) and a fix-check each count as a round. A pass that applied no edits
|
||||||
continues to Step 10: the loop ships with its deferred and noted items named in
|
continues to Step 10: the loop ships with its deferred and noted items named in
|
||||||
the PR body. A blocking finding still open after the last allowed step stops
|
the PR body. A blocking finding still open after the last allowed step stops
|
||||||
the run and names the finding (path, severity, problem) for the owner instead
|
the run and names the finding (path, severity, problem) for the owner instead
|
||||||
of a third full pass.
|
of a third round; the owner may grant one scoped fix-check.
|
||||||
|
|
||||||
`gstack-review-scope summary --since <REVIEW_SINCE>` reads the branch's review-log
|
`gstack-review-scope summary --since <REVIEW_SINCE>` reads the branch's review-log
|
||||||
rows for this invocation and writes the counters the ship receipt carries
|
rows for this invocation and writes the counters the ship receipt carries
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ export interface ShipReceipt {
|
|||||||
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
|
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
|
||||||
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
|
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
|
||||||
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
|
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
|
||||||
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
|
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
|
||||||
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
|
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
|
||||||
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
|
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
|
||||||
head_tree?: string; product_patch_id?: string;
|
head_tree?: string; product_patch_id?: string;
|
||||||
/** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
|
/** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
|
||||||
gstack_version?: string;
|
gstack_version?: string;
|
||||||
review_depth?: { depth: '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[] };
|
review_depth?: { depth: '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[] };
|
||||||
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
|
/** 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[] } };
|
||||||
/** 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. */
|
/** 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. */
|
||||||
@@ -236,11 +236,11 @@ export const ARTIFACT_SCHEMAS = {
|
|||||||
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
|
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
|
||||||
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
|
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
|
||||||
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
|
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
|
||||||
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
|
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
|
||||||
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
|
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
|
||||||
head_tree: str, product_patch_id: str, gstack_version: str,
|
head_tree: str, product_patch_id: str, gstack_version: str,
|
||||||
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
|
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
|
||||||
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, 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 } },
|
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, 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'] } } } },
|
||||||
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_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_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', '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 } } } },
|
||||||
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' } } } } } },
|
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' } } } } } },
|
||||||
|
|||||||
+33
-19
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import type { ResultCodeName } from './result-codes';
|
import type { ResultCodeName } from './result-codes';
|
||||||
import { matchesAny } from './ship-policy';
|
import { matchesAny } from './ship-policy';
|
||||||
|
import { matchRisk, RISK_RULES, type RiskClass } from './review-risk';
|
||||||
|
|
||||||
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
|
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
|
||||||
export type PathClass = 'docs' | 'tests' | 'code';
|
export type PathClass = 'docs' | 'tests' | 'code';
|
||||||
@@ -23,6 +24,7 @@ export type Step = 'full' | 'scoped' | 'fix-check' | '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];
|
||||||
|
/** The review rounds one /ship invocation runs (R51): a full, scoped or fix-check step is a round. */
|
||||||
export const FULL_PASS_CAP = 2;
|
export const FULL_PASS_CAP = 2;
|
||||||
export const SMALL_DIFF_LINES = 50;
|
export const SMALL_DIFF_LINES = 50;
|
||||||
export const RED_TEAM_LINES = 200;
|
export const RED_TEAM_LINES = 200;
|
||||||
@@ -53,7 +55,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
|
|||||||
/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
|
/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
|
||||||
const GATE_PATH = [
|
const GATE_PATH = [
|
||||||
/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
|
/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
|
||||||
/^lib\/review-(depth|collect|scope|targeted)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
|
/^lib\/review-(depth|collect|scope|targeted|risk)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */
|
/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */
|
||||||
@@ -66,7 +68,7 @@ const DATA_CONFIG = [
|
|||||||
|
|
||||||
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
|
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
|
||||||
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
|
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
|
||||||
SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
|
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
|
||||||
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||||
})).digest('hex').slice(0, 16);
|
})).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
@@ -129,8 +131,11 @@ export interface ClassInput {
|
|||||||
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
|
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
|
||||||
*/
|
*/
|
||||||
consumed?: readonly string[] | 'all';
|
consumed?: readonly string[] | 'all';
|
||||||
|
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
|
||||||
|
added?: Record<string, readonly string[]>;
|
||||||
|
removed?: Record<string, readonly string[]>;
|
||||||
}
|
}
|
||||||
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null }
|
export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null; risk: RiskClass[] }
|
||||||
|
|
||||||
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
|
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
|
||||||
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
|
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
|
||||||
@@ -166,30 +171,33 @@ export function classifyDiff(i: ClassInput): ClassResult {
|
|||||||
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
|
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
|
||||||
if (i.registry?.error) errors.push(i.registry.error);
|
if (i.registry?.error) errors.push(i.registry.error);
|
||||||
}
|
}
|
||||||
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null };
|
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true' }) : new Map<RiskClass, string>();
|
||||||
|
const risk = [...riskHits.keys()];
|
||||||
|
reasons.push(...[...riskHits.values()].map(r => `risk: ${r}`));
|
||||||
|
if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null, risk };
|
||||||
let runtimeLines = 0;
|
let runtimeLines = 0;
|
||||||
for (const e of i.perPath) {
|
for (const e of i.perPath) {
|
||||||
if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue;
|
if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue;
|
||||||
if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" });
|
if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" });
|
||||||
else runtimeLines += e.lines;
|
else runtimeLines += e.lines;
|
||||||
}
|
}
|
||||||
return { class: cls, reasons, errors, runtimeLines };
|
return { class: cls, reasons, errors, runtimeLines, risk };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */
|
/** Risk, not size (R51): any classifier error, gate class or risk class is full; everything else is light. A floor only raises; a fixed blocker or a CRITICAL raises light to standard for the next step (planReview). */
|
||||||
export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth {
|
export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; risk?: readonly RiskClass[]; floor?: Depth }): Depth {
|
||||||
const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light';
|
const d: Depth = o.errors.length || o.cls === 'gate' || o.risk?.length ? 'full' : 'light';
|
||||||
return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d;
|
return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string }
|
export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string; risk: RiskClass[] }
|
||||||
|
|
||||||
function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult {
|
function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult {
|
||||||
const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors });
|
const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk });
|
||||||
const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`];
|
const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), ...c.risk.map(r => `risk:${r}`), `runtime=${runtimeLines}`];
|
||||||
if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`);
|
if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`);
|
||||||
const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))];
|
const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))];
|
||||||
return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES };
|
return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, risk: c.risk, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES, risk: c.risk };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */
|
/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */
|
||||||
@@ -249,17 +257,23 @@ export interface NextStep { step: Step; reason: string }
|
|||||||
* The transition table (docs/review-policy.md#transitions). Pass 1 is full. A fixed
|
* The transition table (docs/review-policy.md#transitions). Pass 1 is full. A fixed
|
||||||
* blocking finding earns a second full pass on the range, scoped when `depth` (known
|
* blocking finding earns a second full pass on the range, scoped when `depth` (known
|
||||||
* only to planReview) is below full and the fix range has at most SCOPED_RANGE_LINES
|
* only to planReview) is below full and the fix range has at most SCOPED_RANGE_LINES
|
||||||
* runtime lines; any other fix earns one fix-check; two full passes is the cap (a
|
* runtime lines; any other fix earns one fix-check, except a light pass whose fix range
|
||||||
* scoped pass is one); a blocker still open after any step stops for the owner.
|
* 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
|
||||||
|
* there, or a gate-class edit there, stops for the owner, whose grant may add one scoped fix-check.
|
||||||
*/
|
*/
|
||||||
export function nextStep(h: History, depth?: Depth): NextStep {
|
export function nextStep(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 || fixed ? { step: 'stop', reason: 'the fix-check found a blocking finding: name it for the owner, never a third full pass' } : { step: 'done', reason: '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' };
|
||||||
if (h.blockingOpen && h.fullPasses >= FULL_PASS_CAP) return { step: 'stop', reason: `a blocking finding is open after ${FULL_PASS_CAP} full passes: name it for the owner` };
|
if (h.blockingOpen && h.fullPasses >= FULL_PASS_CAP) return { step: 'stop', reason: `a blocking finding is open after ${FULL_PASS_CAP} rounds: name it for the owner` };
|
||||||
if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' };
|
if (h.blockingOpen) return { step: 'stop', reason: 'a blocking finding is still open in fix scope: name it for the owner' };
|
||||||
if (!fixed) return { step: 'done', reason: 'the step applied no edits' };
|
if (!fixed) return { step: 'done', reason: 'the step applied no edits' };
|
||||||
if ((h.fixedBlocking || h.touchedGate) && h.fullPasses < FULL_PASS_CAP) {
|
if (h.fullPasses >= FULL_PASS_CAP) return h.fixedBlocking || h.touchedGate
|
||||||
|
? { step: 'stop', reason: `${h.fixedBlocking ? 'a blocking finding was fixed' : 'a fix edited a gate-class path'} in round ${FULL_PASS_CAP}, the cap: name it for the owner, whose grant may add one scoped fix-check (review_loop_exception)` }
|
||||||
|
: { step: 'done', reason: `round cap ${FULL_PASS_CAP} reached: the below-floor fixes ship as notes in the PR body and receipt` };
|
||||||
|
if (!h.fixedBlocking && !h.touchedGate && depth === 'light' && h.rangeRuntimeLines === 0) return { step: 'done', reason: 'light depth: the below-floor fixes change no runtime lines, so they ship as notes without a fix-check' };
|
||||||
|
if (h.fixedBlocking || h.touchedGate) {
|
||||||
if (!h.fixedBlocking) return { step: 'full', reason: 'a fix touched a gate-class path: full pass on the range' };
|
if (!h.fixedBlocking) return { step: 'full', reason: 'a fix touched a gate-class path: full pass on the range' };
|
||||||
const n = h.rangeRuntimeLines;
|
const n = h.rangeRuntimeLines;
|
||||||
const keep = h.touchedGate ? 'a fix touched a gate-class path' : depth === undefined ? 'the depth is unknown' : h.pass1Depth === 'full' ? 'pass 1 ran at full depth' : depth === 'full' ? 'the diff is at full depth'
|
const keep = h.touchedGate ? 'a fix touched a gate-class path' : depth === undefined ? 'the depth is unknown' : h.pass1Depth === 'full' ? 'pass 1 ran at full depth' : depth === 'full' ? 'the diff is at full depth'
|
||||||
@@ -267,7 +281,7 @@ export function nextStep(h: History, depth?: Depth): NextStep {
|
|||||||
if (keep) return { step: 'full', reason: `a blocking finding was fixed and ${keep}: full pass on the range` };
|
if (keep) return { step: 'full', reason: `a blocking finding was fixed and ${keep}: full pass on the range` };
|
||||||
return { step: 'scoped', reason: `a blocking finding was fixed on a ${depth} diff with ${n} runtime lines in the fix range (<= ${SCOPED_RANGE_LINES}): scoped pass on the range` };
|
return { step: 'scoped', reason: `a blocking finding was fixed on a ${depth} diff with ${n} runtime lines in the fix range (<= ${SCOPED_RANGE_LINES}): scoped pass on the range` };
|
||||||
}
|
}
|
||||||
return { step: 'fix-check', reason: h.fixedBlocking ? `full-pass cap ${FULL_PASS_CAP} reached: one fix-check reads the fix` : 'only below-floor fixes: one fix-check reads the touched hunks' };
|
return { step: 'fix-check', reason: 'only below-floor fixes: one fix-check reads the touched hunks (round 2)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -401,7 +415,7 @@ export function planReview(i: PlanInput): ReviewPlan {
|
|||||||
} else if (next.step === 'full') {
|
} else if (next.step === 'full') {
|
||||||
const full = stepDepth === 'full';
|
const full = stepDepth === 'full';
|
||||||
const dcls: DiffClass = full ? 'gate' : cls;
|
const dcls: DiffClass = full ? 'gate' : cls;
|
||||||
const raised = !full && runtimeLines <= SMALL_DIFF_LINES;
|
const raised = !full && (raise || runtimeLines <= SMALL_DIFF_LINES);
|
||||||
const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0);
|
const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0);
|
||||||
const checklists = batchChecklists(dcls, i, skipped);
|
const checklists = batchChecklists(dcls, i, skipped);
|
||||||
if (checklists.length) add('specialist', 'batched', specialist, full ? [] : docsAndOutputs, { checklists });
|
if (checklists.length) add('specialist', 'batched', specialist, full ? [] : docsAndOutputs, { checklists });
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* review-risk — the risk signals behind /ship's review depth (R51, docs/review-policy.md#review-depth) and the
|
||||||
|
* targeted pass (lib/review-targeted.ts). A leaf module: callers pass only code-class paths with their added
|
||||||
|
* and deleted lines, so it imports neither the depth nor the targeted module.
|
||||||
|
*
|
||||||
|
* money, auth and secret are the targeted pass's matchers (GST-257). security is a trust-boundary path or line
|
||||||
|
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink); data is user data (a path
|
||||||
|
* naming personal or stored data, or a line that drops or bulk-deletes it). Path and identifier signals are
|
||||||
|
* token-bounded: costume never names cost.
|
||||||
|
*/
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const;
|
||||||
|
export type RiskClass = (typeof RISK_CLASSES)[number];
|
||||||
|
|
||||||
|
export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices'];
|
||||||
|
/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */
|
||||||
|
export const MONEY_LINE = [
|
||||||
|
/\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i,
|
||||||
|
/\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/,
|
||||||
|
/\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/,
|
||||||
|
];
|
||||||
|
export const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles'];
|
||||||
|
export const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz'];
|
||||||
|
export const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i;
|
||||||
|
/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */
|
||||||
|
export const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`;
|
||||||
|
export const SECRET_LINE = [
|
||||||
|
new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`),
|
||||||
|
new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`),
|
||||||
|
new RegExp(String.raw`\$\{?${KEY_NAME}`),
|
||||||
|
/\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape'];
|
||||||
|
const SECURITY_LINE = [
|
||||||
|
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w])execSync\s*\(/, /\bchild_process\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
|
||||||
|
/(?<![.\w])eval\s*\(/, /(?:^|[;&|])\s*eval\s+["'$]/, /\bnew\s+Function\s*\(/,
|
||||||
|
/\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/, /\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/,
|
||||||
|
/\brmSync\s*\([^)]*recursive\s*:\s*true/, /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/,
|
||||||
|
/\b(?:innerHTML|outerHTML)\s*\+?=/, /\bdangerouslySetInnerHTML\b/, /\bv-html\b/, /\{@html\b/, /\binsertAdjacentHTML\s*\(/, /\bdocument\.write\s*\(/,
|
||||||
|
];
|
||||||
|
const DATA_PATH_TOKENS = ['pii', 'gdpr', 'ccpa', 'privacy', 'personal', 'account', 'accounts', 'customer', 'customers', 'cookie', 'cookies', 'persistence', 'storage', 'database'];
|
||||||
|
const DATA_LINE = [
|
||||||
|
/\bDELETE\s+FROM\b/, /\b(?:drop\s+(?:table|column|database|schema)|truncate\s+table)\b/i, /\bdelete\s+from\s+[\w."`]+\s*(?:where\b|;|$|["'`)])/i,
|
||||||
|
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */
|
||||||
|
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
|
||||||
|
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
|
||||||
|
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||||
|
})).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
export interface RiskInput {
|
||||||
|
/** Code-class paths only: the caller drops docs, tests and data its own classifier set aside. */
|
||||||
|
codePaths: readonly string[];
|
||||||
|
added?: Record<string, readonly string[]>;
|
||||||
|
removed?: Record<string, readonly string[]>;
|
||||||
|
/** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */
|
||||||
|
scopeAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The risk classes the code paths touch, the first reason per class, in RISK_CLASSES order. Nothing fails closed here: input errors are the caller's. */
|
||||||
|
export function matchRisk(i: RiskInput): Map<RiskClass, string> {
|
||||||
|
const why = new Map<RiskClass, string>();
|
||||||
|
const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); };
|
||||||
|
for (const p of i.codePaths) {
|
||||||
|
const t = tokens(p);
|
||||||
|
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
|
||||||
|
if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p);
|
||||||
|
if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', 'money path: ' + p);
|
||||||
|
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
|
||||||
|
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
|
||||||
|
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
||||||
|
if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', 'money line: ' + p);
|
||||||
|
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
|
||||||
|
if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p);
|
||||||
|
if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Map(RISK_CLASSES.filter(c => why.has(c)).map(c => [c, why.get(c)!]));
|
||||||
|
}
|
||||||
@@ -300,6 +300,8 @@ export interface ReviewLoopSummary {
|
|||||||
passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown';
|
passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown';
|
||||||
/** One-reviewer re-checks of a fix (docs/review-policy.md#transitions); never passes. */
|
/** One-reviewer re-checks of a fix (docs/review-policy.md#transitions); never passes. */
|
||||||
fix_checks?: number;
|
fix_checks?: number;
|
||||||
|
/** Review rounds (R51): passes plus fix-checks; the loop caps them at 2. */
|
||||||
|
rounds?: number;
|
||||||
/** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check, targeted). */
|
/** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check, targeted). */
|
||||||
dispatches?: Record<string, number>;
|
dispatches?: Record<string, number>;
|
||||||
/** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */
|
/** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */
|
||||||
@@ -370,6 +372,7 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
|
|||||||
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
|
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
|
||||||
const fixChecks = loopRows.filter(r => r.skill === FIX_CHECK_SKILL).length;
|
const fixChecks = loopRows.filter(r => r.skill === FIX_CHECK_SKILL).length;
|
||||||
if (fixChecks) out.fix_checks = fixChecks;
|
if (fixChecks) out.fix_checks = fixChecks;
|
||||||
|
out.rounds = passes.length + fixChecks;
|
||||||
const byRole: Record<string, number> = {};
|
const byRole: Record<string, number> = {};
|
||||||
let bytes = 0; let measured = false;
|
let bytes = 0; let measured = false;
|
||||||
for (const row of rows.filter(r => inRun(r) && DISPATCH_SKILLS.includes(r.skill))) {
|
for (const row of rows.filter(r => inRun(r) && DISPATCH_SKILLS.includes(r.skill))) {
|
||||||
|
|||||||
+3
-33
@@ -21,29 +21,11 @@ import { createHash } from 'node:crypto';
|
|||||||
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 { classifyPath } from './review-depth';
|
import { classifyPath } from './review-depth';
|
||||||
|
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
|
||||||
|
|
||||||
export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const;
|
export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const;
|
||||||
export type TargetedClass = (typeof TARGETED_CLASSES)[number];
|
export type TargetedClass = (typeof TARGETED_CLASSES)[number];
|
||||||
|
|
||||||
const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices'];
|
|
||||||
/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */
|
|
||||||
const MONEY_LINE = [
|
|
||||||
/\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i,
|
|
||||||
/\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/,
|
|
||||||
/\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/,
|
|
||||||
];
|
|
||||||
const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles'];
|
|
||||||
const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz'];
|
|
||||||
const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i;
|
|
||||||
/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */
|
|
||||||
const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`;
|
|
||||||
const SECRET_LINE = [
|
|
||||||
new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`),
|
|
||||||
new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`),
|
|
||||||
new RegExp(String.raw`\$\{?${KEY_NAME}`),
|
|
||||||
/\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/,
|
|
||||||
];
|
|
||||||
|
|
||||||
/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */
|
/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */
|
||||||
export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({
|
export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({
|
||||||
TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
||||||
@@ -65,8 +47,6 @@ export interface TargetedInput {
|
|||||||
}
|
}
|
||||||
export interface TargetedResult { classes: TargetedClass[]; reasons: string[]; errors: string[]; rules: string }
|
export interface TargetedResult { classes: TargetedClass[]; reasons: string[]; errors: string[]; rules: string }
|
||||||
|
|
||||||
const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
||||||
|
|
||||||
/** The classes a diff touches, in TARGETED_CLASSES order, with one reason per class; any input error returns all four. */
|
/** The classes a diff touches, in TARGETED_CLASSES order, with one reason per class; any input error returns all four. */
|
||||||
export function targetedClasses(i: TargetedInput): TargetedResult {
|
export function targetedClasses(i: TargetedInput): TargetedResult {
|
||||||
const errors = (i.errors ?? []).map(e => `${e.code} (${e.input})`);
|
const errors = (i.errors ?? []).map(e => `${e.code} (${e.input})`);
|
||||||
@@ -77,18 +57,8 @@ export function targetedClasses(i: TargetedInput): TargetedResult {
|
|||||||
if (code.length && i.gateTagged === null) errors.push('gate scan unavailable');
|
if (code.length && i.gateTagged === null) errors.push('gate scan unavailable');
|
||||||
if (errors.length) return { classes: [...TARGETED_CLASSES], reasons: errors.map(e => `fail-closed: ${e}`), errors, rules: TARGETED_RULES };
|
if (errors.length) return { classes: [...TARGETED_CLASSES], reasons: errors.map(e => `fail-closed: ${e}`), errors, rules: TARGETED_RULES };
|
||||||
const why = new Map<TargetedClass, string>();
|
const why = new Map<TargetedClass, string>();
|
||||||
const hit = (c: TargetedClass, reason: string) => { if (!why.has(c)) why.set(c, reason); };
|
if ((i.gateTagged ?? 0) > 0) why.set('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
|
||||||
if ((i.gateTagged ?? 0) > 0) hit('gate', `gate scan tagged ${i.gateTagged} hunk(s)`);
|
for (const [c, reason] of matchRisk({ codePaths: code, added: i.added, removed: i.removed, scopeAuth: s!.SCOPE_AUTH === 'true' })) if ((TARGETED_CLASSES as readonly string[]).includes(c)) why.set(c as TargetedClass, reason);
|
||||||
for (const p of code) {
|
|
||||||
const t = tokens(p);
|
|
||||||
if (s!.SCOPE_AUTH === 'true' && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', `auth path: ${p}`);
|
|
||||||
if (SECRET_PATH.test(p)) hit('secret', `secret path: ${p}`);
|
|
||||||
if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', `money path: ${p}`);
|
|
||||||
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
|
||||||
if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', `money line: ${p}`);
|
|
||||||
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', `secret line: ${p}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const classes = TARGETED_CLASSES.filter(c => why.has(c));
|
const classes = TARGETED_CLASSES.filter(c => why.has(c));
|
||||||
return { classes, reasons: classes.map(c => why.get(c)!), errors: [], rules: TARGETED_RULES };
|
return { classes, reasons: classes.map(c => why.get(c)!), errors: [], rules: TARGETED_RULES };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -302,7 +302,7 @@ export function segmentEstimate(cal: Calibration, unit: string, from: string, to
|
|||||||
* section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's
|
* section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's
|
||||||
* context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M
|
* context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M
|
||||||
* floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that
|
* floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that
|
||||||
* share (12M) cannot pay for its child. A light-depth diff (runtime lines at most SMALL_DIFF_LINES) keeps
|
* share (12M) cannot pay for its child. A small diff (runtime lines at most SMALL_DIFF_LINES) keeps
|
||||||
* its segment when the segment's calibrated calls at this run's own per-call charge stay under that.
|
* its segment when the segment's calibrated calls at this run's own per-call charge stay under that.
|
||||||
*/
|
*/
|
||||||
export const SMALL_RUN_LINES = SMALL_DIFF_LINES;
|
export const SMALL_RUN_LINES = SMALL_DIFF_LINES;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: strin
|
|||||||
tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
||||||
}
|
}
|
||||||
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
|
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
|
||||||
return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) };
|
return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12), risk: d.risk };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need];
|
export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need];
|
||||||
@@ -182,7 +182,7 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
|
|||||||
const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
|
const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
|
||||||
const have = rd?.depth;
|
const have = rd?.depth;
|
||||||
const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`;
|
const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`;
|
||||||
const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`;
|
const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs} rounds=${r.review_loop?.rounds ?? (r.review_loop ? r.review_loop.passes + (r.review_loop.fix_checks ?? 0) : 'none')}`;
|
||||||
if (have && !depthBelow(have, need.depth)) return { ok: true, line };
|
if (have && !depthBelow(have, need.depth)) return { ok: true, line };
|
||||||
const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one';
|
const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one';
|
||||||
const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; ');
|
const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; ');
|
||||||
|
|||||||
+2
-1
@@ -129,7 +129,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
|
|||||||
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
||||||
const loop = parseReviewLoop(r.review_loop);
|
const loop = parseReviewLoop(r.review_loop);
|
||||||
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
|
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
|
||||||
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
|
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.rounds !== undefined ? ` rounds=${loop.rounds}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
|
||||||
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
|
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
|
||||||
if (r.base_red && typeof r.base_red.base === 'string') parts.push(`base_red=${r.base_red.base.slice(0, 12)}`);
|
if (r.base_red && typeof r.base_red.base === 'string') parts.push(`base_red=${r.base_red.base.slice(0, 12)}`);
|
||||||
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
||||||
@@ -326,6 +326,7 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
|
|||||||
if (n('fixed') !== undefined) out.fixed = n('fixed');
|
if (n('fixed') !== undefined) out.fixed = n('fixed');
|
||||||
if (typeof v.scope === 'string') out.scope = v.scope.slice(0, 20);
|
if (typeof v.scope === 'string') out.scope = v.scope.slice(0, 20);
|
||||||
if (n('fix_checks') !== undefined) out.fix_checks = n('fix_checks');
|
if (n('fix_checks') !== undefined) out.fix_checks = n('fix_checks');
|
||||||
|
if (n('rounds') !== undefined) out.rounds = n('rounds');
|
||||||
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
|
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
|
||||||
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
|
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
|
||||||
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
|
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.92.44",
|
"version": "1.92.45",
|
||||||
"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",
|
||||||
|
|||||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+2
-2
@@ -76,8 +76,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+2
-2
@@ -208,8 +208,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+2
-2
@@ -210,8 +210,8 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
|||||||
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
**Review policy** (docs/review-policy.md): only a finding that would fail a test,
|
||||||
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
break production, or lose data or secrets blocks a pass; the rest is a PR-body
|
||||||
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only
|
||||||
raises it). Pass 1 reads the diff, later passes the range; the cap is two full
|
raises it). Pass 1 reads the diff, later passes the range; the cap is two rounds,
|
||||||
passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
fix-checks included. `NO FINDINGS` is complete. `ship: review_scope` (Step 3)
|
||||||
is the knob; the receipt's `review_loop` records the outcome.
|
is the knob; the receipt's `review_loop` records the outcome.
|
||||||
|
|
||||||
### Ship control flow
|
### Ship control flow
|
||||||
|
|||||||
+46
-19
@@ -44,8 +44,23 @@ describe('nextStep: the transition table', () => {
|
|||||||
test('a fixed blocker earns full pass 2', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true }).step).toBe('full'));
|
test('a fixed blocker earns full pass 2', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true }).step).toBe('full'));
|
||||||
test('below-floor fixes earn one fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }).step).toBe('fix-check'));
|
test('below-floor fixes earn one fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }).step).toBe('fix-check'));
|
||||||
test('a fix touching gate code earns a full pass instead of a fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, touchedGate: true }).step).toBe('full'));
|
test('a fix touching gate code earns a full pass instead of a fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, touchedGate: true }).step).toBe('full'));
|
||||||
test('at the full-pass cap a fixed blocker gets a fix-check', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true }).step).toBe('fix-check'));
|
// R51: two rounds is the cap, a fix-check included. A blocker open or fixed in round 2 stops for the owner (a grant may add
|
||||||
test('at the cap a gate-touching fix still gets a fix-check, never a third full pass', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('fix-check'));
|
// one scoped fix-check); below-floor fixes in round 2 finish with notes; a light pass whose fixes change no runtime lines is done.
|
||||||
|
test('a blocker fixed in round 2 stops for the owner, never a third round', () => {
|
||||||
|
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('scoped fix-check') });
|
||||||
|
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('stop');
|
||||||
|
});
|
||||||
|
test('below-floor fixes in round 2 finish with notes, a fix-check included', () => {
|
||||||
|
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') });
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true })).toMatchObject({ step: 'done', reason: expect.stringContaining('notes') });
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'fix-check', fixedBelow: true, fixedBlocking: true }).step).toBe('stop');
|
||||||
|
});
|
||||||
|
test('a light pass 1 whose below-floor fixes change no runtime lines is done; a runtime fix or a deeper diff keeps the fix-check', () => {
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'light').step).toBe('done');
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 3 }, 'light').step).toBe('fix-check');
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, rangeRuntimeLines: 0 }, 'full').step).toBe('fix-check');
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }, 'light').step).toBe('fix-check');
|
||||||
|
});
|
||||||
test('a blocker open at the cap stops', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
test('a blocker open at the cap stops', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||||
test('a blocker open with no fix stops', () => expect(nextStep({ fullPasses: 1, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
test('a blocker open with no fix stops', () => expect(nextStep({ fullPasses: 1, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||||
test('a fix-check that finds a blocker stops, never a third full pass', () => {
|
test('a fix-check that finds a blocker stops, never a third full pass', () => {
|
||||||
@@ -83,8 +98,12 @@ describe('planReview', () => {
|
|||||||
expect(p.depth).toBe('light');
|
expect(p.depth).toBe('light');
|
||||||
expect(roles(p)).toEqual([]);
|
expect(roles(p)).toEqual([]);
|
||||||
});
|
});
|
||||||
test('code class pass 1: specialists at medium, the native adversarial at full, the outside voice at its own', () => {
|
test('non-risky code pass 1 is light at any size (R51): one batched reader carrying the Red Team prompt and the native adversarial', () => {
|
||||||
const p = planReview(base({}));
|
const p = planReview(base({ diffLines: 900 }));
|
||||||
|
expect([p.depth, roles(p)]).toEqual(['light', ['specialist:batched:full', 'adversarial-native:adversarial:full']]);
|
||||||
|
});
|
||||||
|
test('code class pass 1 at standard: specialists at medium, the native adversarial at full, the outside voice at its own', () => {
|
||||||
|
const p = planReview(base({ floor: 'standard' }));
|
||||||
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||||
expect(batch(p)).toEqual(['testing', 'security']);
|
expect(batch(p)).toEqual(['testing', 'security']);
|
||||||
expect(p.skipped).toContain('maintainability, performance, simplification (lean ensemble: read only when forced)');
|
expect(p.skipped).toContain('maintainability, performance, simplification (lean ensemble: read only when forced)');
|
||||||
@@ -162,10 +181,10 @@ describe('GSTA-131 pass-1 review repairs', () => {
|
|||||||
expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true);
|
expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true);
|
||||||
});
|
});
|
||||||
test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => {
|
test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => {
|
||||||
const all = planReview(base({ force: 'all' }));
|
const all = planReview(base({ floor: 'standard', force: 'all' }));
|
||||||
expect(all.dispatches.filter(d => d.role === 'specialist').length).toBe(1);
|
expect(all.dispatches.filter(d => d.role === 'specialist').length).toBe(1);
|
||||||
expect([...batch(all)!].sort()).toEqual(['api-contract', 'data-migration', 'design', 'maintainability', 'performance', 'security', 'simplification', 'state-machines', 'testing']);
|
expect([...batch(all)!].sort()).toEqual(['api-contract', 'data-migration', 'design', 'maintainability', 'performance', 'security', 'simplification', 'state-machines', 'testing']);
|
||||||
const has = (o: Partial<PlanInput>) => roles(planReview(base(o))).some(r => r.startsWith('red-team'));
|
const has = (o: Partial<PlanInput>) => roles(planReview(base({ floor: 'standard', ...o }))).some(r => r.startsWith('red-team'));
|
||||||
expect(has({ diffLines: 120 })).toBe(false);
|
expect(has({ diffLines: 120 })).toBe(false);
|
||||||
expect(has({ diffLines: 201 })).toBe(true);
|
expect(has({ diffLines: 201 })).toBe(true);
|
||||||
expect(has({ diffLines: 120, criticalFound: true })).toBe(true);
|
expect(has({ diffLines: 120, criticalFound: true })).toBe(true);
|
||||||
@@ -175,20 +194,20 @@ describe('GSTA-131 pass-1 review repairs', () => {
|
|||||||
describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
||||||
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}`);
|
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}`);
|
||||||
test('Red Team runs on code past 200 lines or after a CRITICAL, and on every gate diff', () => {
|
test('Red Team runs on code past 200 lines or after a CRITICAL, and on every gate diff', () => {
|
||||||
expect(roles(planReview(base({ diffLines: 120 })))).not.toContain('red-team:red-team');
|
expect(roles(planReview(base({ diffLines: 120, floor: 'standard' })))).not.toContain('red-team:red-team');
|
||||||
expect(roles(planReview(base({ diffLines: 201 })))).toContain('red-team:red-team');
|
expect(roles(planReview(base({ diffLines: 201, floor: 'standard' })))).toContain('red-team:red-team');
|
||||||
expect(roles(planReview(base({ diffLines: 120, criticalFound: true })))).toContain('red-team:red-team');
|
expect(roles(planReview(base({ diffLines: 120, floor: 'standard', criticalFound: true })))).toContain('red-team:red-team');
|
||||||
expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team');
|
expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team');
|
||||||
});
|
});
|
||||||
test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => {
|
test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => {
|
||||||
const picked = (scope: Record<string, string>) => batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
|
const picked = (scope: Record<string, string>) => batch(planReview(base({ floor: 'standard', scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
|
||||||
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('data-migration');
|
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('data-migration');
|
||||||
expect(picked({ SCOPE_API: 'true' })).toContain('api-contract');
|
expect(picked({ SCOPE_API: 'true' })).toContain('api-contract');
|
||||||
expect(picked({ SCOPE_FRONTEND: 'true' })).toEqual(['testing', 'security', 'design']);
|
expect(picked({ SCOPE_FRONTEND: 'true' })).toEqual(['testing', 'security', 'design']);
|
||||||
expect(batch(planReview(base({ scope: {} })))).toContain('state-machines');
|
expect(batch(planReview(base({ scope: {} })))).toContain('state-machines');
|
||||||
expect(batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('state-machines');
|
expect(batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('state-machines');
|
||||||
expect(batch(planReview(base({ force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']);
|
expect(batch(planReview(base({ floor: 'standard', force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']);
|
||||||
expect(batch(planReview(base({ gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']);
|
expect(batch(planReview(base({ floor: 'standard', gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']);
|
||||||
const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' }));
|
const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' }));
|
||||||
expect(docsAll.class).toBe('docs');
|
expect(docsAll.class).toBe('docs');
|
||||||
expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5);
|
expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5);
|
||||||
@@ -215,8 +234,12 @@ describe('review depth: the matrix', () => {
|
|||||||
expect(p.skipped.join('; ')).toContain('red-team (light depth');
|
expect(p.skipped.join('; ')).toContain('red-team (light depth');
|
||||||
expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false);
|
expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false);
|
||||||
});
|
});
|
||||||
test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => {
|
test('runtime 51 non-risky code stays light (R51: size never raises the depth)', () => {
|
||||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 }));
|
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 }));
|
||||||
|
expect([p.depth, p.runtimeLines, roles(p)]).toEqual(['light', 51, ['specialist:batched:full', 'adversarial-native:adversarial:full']]);
|
||||||
|
});
|
||||||
|
test('standard (floor or raise): thresholds read runtime lines, reviewers read their own hunks', () => {
|
||||||
|
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454, floor: 'standard' }));
|
||||||
expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]);
|
expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]);
|
||||||
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||||
expect(batch(p)).toEqual(['testing', 'security']);
|
expect(batch(p)).toEqual(['testing', 'security']);
|
||||||
@@ -274,14 +297,16 @@ describe('review depth: the matrix', () => {
|
|||||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full');
|
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full');
|
||||||
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard');
|
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard');
|
||||||
expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full');
|
expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full');
|
||||||
expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard');
|
expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('light');
|
||||||
|
expect(depthFor({ cls: 'code', runtimeLines: 3, errors: [], risk: ['data'] })).toBe('full');
|
||||||
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' }));
|
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' }));
|
||||||
expect(p.depth).toBe('full');
|
expect(p.depth).toBe('full');
|
||||||
expect(p.depthReasons).toContain('floor:full');
|
expect(p.depthReasons).toContain('floor:full');
|
||||||
});
|
});
|
||||||
test('depthOfCommitInput gives the same depth as the plan for the same input', () => {
|
test('depthOfCommitInput gives the same depth as the plan for the same input', () => {
|
||||||
const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 };
|
const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 };
|
||||||
expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] });
|
expect(depthOfCommitInput(i)).toMatchObject({ depth: 'light', runtimeLines: 51, rules: DEPTH_RULES, errors: [], risk: [] });
|
||||||
|
expect(depthOfCommitInput({ ...i, added: { 'src/app.ts': ['const spend_cap_usd = 5;'] } })).toMatchObject({ depth: 'full', risk: ['money'] });
|
||||||
expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full');
|
expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -300,6 +325,7 @@ describe('review depth: diff commands and the rules hash', () => {
|
|||||||
try {
|
try {
|
||||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||||
|
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||||
const hashOf = (text: string) => {
|
const hashOf = (text: string) => {
|
||||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
|
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
|
||||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||||
@@ -369,7 +395,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
|
|||||||
expect(r(p)).toEqual(['fix-check:fix-check', 'red-team:red-team', 'adversarial-native:adversarial']);
|
expect(r(p)).toEqual(['fix-check:fix-check', 'red-team:red-team', 'adversarial-native:adversarial']);
|
||||||
});
|
});
|
||||||
test('the scoped pass counts toward the cap, and a blocker it leaves stops for the owner', () => {
|
test('the scoped pass counts toward the cap, and a blocker it leaves stops for the owner', () => {
|
||||||
expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('fix-check');
|
expect(nextStep(fixed(10, { fullPasses: FULL_PASS_CAP }), 'standard').step).toBe('stop');
|
||||||
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true, rangeRuntimeLines: 10 }, 'light').step).toBe('stop');
|
expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true, rangeRuntimeLines: 10 }, 'light').step).toBe('stop');
|
||||||
const at = (m: number) => new Date(Date.UTC(2026, 9, 28, 10, m)).toISOString();
|
const at = (m: number) => new Date(Date.UTC(2026, 9, 28, 10, m)).toISOString();
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -397,6 +423,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
|
|||||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||||
expect(src).toContain('SCOPED_RANGE_LINES = 150');
|
expect(src).toContain('SCOPED_RANGE_LINES = 150');
|
||||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||||
|
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151'));
|
fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151'));
|
||||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||||
const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||||
@@ -491,11 +518,11 @@ describe('lean ensemble: one batched specialist at standard and full depth (GSTA
|
|||||||
expect(specialists(full).map(d => [d.name, d.effort, d.checklists])).toEqual([['batched', 'medium', ['testing', 'security', 'state-machines']]]);
|
expect(specialists(full).map(d => [d.name, d.effort, d.checklists])).toEqual([['batched', 'medium', ['testing', 'security', 'state-machines']]]);
|
||||||
});
|
});
|
||||||
test('a standard code plan reads one batched reader on its own hunks, and a forced checklist joins it', () => {
|
test('a standard code plan reads one batched reader on its own hunks, and a forced checklist joins it', () => {
|
||||||
const p = planReview(base({ diffLines: 400, scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } }));
|
const p = planReview(base({ diffLines: 400, floor: 'standard', scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } }));
|
||||||
expect(specialists(p).map(d => [d.name, d.checklists])).toEqual([['batched', ['testing', 'security']]]);
|
expect(specialists(p).map(d => [d.name, d.checklists])).toEqual([['batched', ['testing', 'security']]]);
|
||||||
expect(roles(p)).toContain('red-team:red-team:full');
|
expect(roles(p)).toContain('red-team:red-team:full');
|
||||||
expect(batch(planReview(base({ force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']);
|
expect(batch(planReview(base({ floor: 'standard', force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']);
|
||||||
expect(specialists(planReview(base({ tiers: false })))[0]!.effort).toBe('host');
|
expect(specialists(planReview(base({ floor: 'standard', tiers: false })))[0]!.effort).toBe('host');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ const NO_LOSS_REPLAYS = {
|
|||||||
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
|
'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)",
|
||||||
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
|
'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)",
|
||||||
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged",
|
'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing 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",
|
||||||
} 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 }> = {
|
||||||
@@ -77,7 +78,8 @@ 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: '4542785a1e217d8dd054985978b42c055b9fd79082579cb7664f187f7066e48e', replay: 'GST-257' },
|
'lib/review-depth.ts': { sha: 'd649569a163c03476c6e0c5ea295f5d02088f96aab85fbc7d558f2ac2b27006a', replay: 'R51' },
|
||||||
|
'lib/review-risk.ts': { sha: 'dbef7475d467e17340b21a2bacb52a16a7ccb1ee66291900ed2d36dfb5cfb7f2', replay: 'R51' },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ describe('Capy pass program (fake host, real bins)', () => {
|
|||||||
fs.mkdirSync(files, { recursive: true });
|
fs.mkdirSync(files, { recursive: true });
|
||||||
const fc: Record<string, string> = {};
|
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!); }
|
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 planFlags = ['--pass', '2', '--last', 'full', '--fixed-blocking'];
|
const planFlags = ['--pass', '1', '--last', 'full', '--fixed-below'];
|
||||||
const missing = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fc0'), pass: 'x19fc0', planFlags });
|
const missing = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fc0'), pass: 'x19fc0', planFlags });
|
||||||
expect(missing).toContain('STOP: gstack-review-dispatch pass exited');
|
expect(missing).toContain('STOP: gstack-review-dispatch pass exited');
|
||||||
expect(missing).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests');
|
expect(missing).toContain('the plan dispatches a fix-check: pass needs --fix-list, --hunks and --tests');
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ describe('gstack-review-plan', () => {
|
|||||||
});
|
});
|
||||||
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders specialists and audits at host effort', () => {
|
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders specialists and audits at host effort', () => {
|
||||||
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
|
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
|
||||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout);
|
||||||
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
|
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
|
||||||
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').map((d: any) => d.effort)).toEqual(expect.arrayContaining(['medium']));
|
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').map((d: any) => d.effort)).toEqual(expect.arrayContaining(['medium']));
|
||||||
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'medium')).toBe(true);
|
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'medium')).toBe(true);
|
||||||
expect(plan.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
expect(plan.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
||||||
expect(plan.audit_effort).toBe('light');
|
expect(plan.audit_effort).toBe('light');
|
||||||
expect(run(r, []).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines');
|
expect(run(r, ['--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines');
|
||||||
const host = JSON.parse(run(r, ['--json', '--no-tiers']).stdout);
|
const host = JSON.parse(run(r, ['--json', '--no-tiers', '--depth-floor', 'standard']).stdout);
|
||||||
expect(host.audit_effort).toBe('host');
|
expect(host.audit_effort).toBe('host');
|
||||||
expect(host.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'host')).toBe(true);
|
expect(host.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'host')).toBe(true);
|
||||||
expect(host.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
expect(host.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
|
||||||
expect(run(r, ['--no-tiers']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines');
|
expect(run(r, ['--no-tiers', '--depth-floor', 'standard']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines');
|
||||||
});
|
});
|
||||||
test('a below-floor fix after pass 1 plans one fix-check', () => {
|
test('a below-floor fix after pass 1 plans one fix-check', () => {
|
||||||
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
|
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
|
||||||
@@ -132,7 +132,7 @@ describe('gstack-review-plan: review depth', () => {
|
|||||||
const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) });
|
const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) });
|
||||||
const out = run(r, []).stdout;
|
const out = run(r, []).stdout;
|
||||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||||
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m);
|
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10 codes=none$/m);
|
||||||
expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`);
|
expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`);
|
||||||
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,security,state-machines,red-team');
|
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,security,state-machines,red-team');
|
||||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
|
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
|
||||||
@@ -149,7 +149,7 @@ describe('gstack-review-plan: review depth', () => {
|
|||||||
});
|
});
|
||||||
test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => {
|
test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => {
|
||||||
const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' });
|
const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' });
|
||||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
const plan = JSON.parse(run(r, ['--json', '--depth-floor', 'standard']).stdout);
|
||||||
expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } });
|
expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } });
|
||||||
expect(plan.rules).toMatch(/^[0-9a-f]{16}$/);
|
expect(plan.rules).toMatch(/^[0-9a-f]{16}$/);
|
||||||
expect(plan.dispatches.find((d: any) => d.name === 'batched').exclude).toEqual([':(exclude)README.md']);
|
expect(plan.dispatches.find((d: any) => d.name === 'batched').exclude).toEqual([':(exclude)README.md']);
|
||||||
@@ -157,7 +157,7 @@ describe('gstack-review-plan: review depth', () => {
|
|||||||
});
|
});
|
||||||
test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => {
|
test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => {
|
||||||
const r = repo({ 'src/a.ts': code(5) });
|
const r = repo({ 'src/a.ts': code(5) });
|
||||||
expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m);
|
expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5,floor:full /m);
|
||||||
const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' });
|
const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' });
|
||||||
expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m);
|
expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m);
|
||||||
expect(run(r, ['--depth-floor', 'light']).status).toBe(2);
|
expect(run(r, ['--depth-floor', 'light']).status).toBe(2);
|
||||||
@@ -190,7 +190,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => {
|
|||||||
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
||||||
expect(out).toMatch(/^REVIEW_PLAN: class=code pass=2 next=scoped dispatches=3 /m);
|
expect(out).toMatch(/^REVIEW_PLAN: class=code pass=2 next=scoped dispatches=3 /m);
|
||||||
expect(out).toContain(`FIX_RANGE: runtime_lines=24 base=${rangeBase}`);
|
expect(out).toContain(`FIX_RANGE: runtime_lines=24 base=${rangeBase}`);
|
||||||
expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a standard diff with 24 runtime lines in the fix range/m);
|
expect(out).toMatch(/^NEXT: scoped \(a blocking finding was fixed on a light diff with 24 runtime lines in the fix range/m);
|
||||||
expect(out).toContain('DISPATCH: fix-check fix-check effort=full');
|
expect(out).toContain('DISPATCH: fix-check fix-check effort=full');
|
||||||
expect(out).toContain('DISPATCH: red-team red-team effort=full');
|
expect(out).toContain('DISPATCH: red-team red-team effort=full');
|
||||||
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full');
|
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full');
|
||||||
@@ -221,7 +221,7 @@ describe('gstack-review-plan: the scoped second pass (GSTA-152 D6)', () => {
|
|||||||
fs.writeFileSync(path.join(r.dir, 'src', 'b.ts'), code(2, 'b'));
|
fs.writeFileSync(path.join(r.dir, 'src', 'b.ts'), code(2, 'b'));
|
||||||
git('add', '.'); git('commit', '-qm', 'fix: revert the workflow edit');
|
git('add', '.'); git('commit', '-qm', 'fix: revert the workflow edit');
|
||||||
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
const out = run(r, [...pass2, '--range-base', rangeBase]).stdout;
|
||||||
expect(out).toMatch(/^REVIEW_DEPTH: standard /m);
|
expect(out).toMatch(/^REVIEW_DEPTH: light .* step=standard$/m);
|
||||||
expect(out).toMatch(/^NEXT: full \(a blocking finding was fixed and a fix touched a gate-class path/m);
|
expect(out).toMatch(/^NEXT: full \(a blocking finding was fixed and a fix touched a gate-class path/m);
|
||||||
expect(JSON.parse(run(r, [...pass2, '--range-base', rangeBase, '--json']).stdout)).toMatchObject({ next: { step: 'full' }, fix_range: { touched_gate: true } });
|
expect(JSON.parse(run(r, [...pass2, '--range-base', rangeBase, '--json']).stdout)).toMatchObject({ next: { step: 'full' }, fix_range: { touched_gate: true } });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* R51: review scales to risk, not size (docs/review-policy.md#review-depth, #risk-classes). The risk classes
|
||||||
|
* (lib/review-risk.ts), the round cap, and the routing table: every gate,
|
||||||
|
* security, user-data and money fixture still plans the full panel, and non-risky code is light at any size.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { classifyDiff, nextStep, planReview, scanNumbers, type PlanInput } from '../lib/review-depth';
|
||||||
|
import { matchRisk, RISK_RULES } from '../lib/review-risk';
|
||||||
|
import { targetedClasses } from '../lib/review-targeted';
|
||||||
|
import { parseReviewLoop, receiptSummaryLine } from '../lib/ship-receipt';
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dir, '..');
|
||||||
|
const risk = (codePaths: string[], added: Record<string, string[]> = {}, scopeAuth = false) => [...matchRisk({ codePaths, added, removed: {}, scopeAuth }).keys()];
|
||||||
|
|
||||||
|
describe('risk classes', () => {
|
||||||
|
// Value: protects=security and user-data code reaches the full panel; fails_when=a trust-boundary or data-loss signal is missed,
|
||||||
|
// or an unrelated path or line raises a class; seam=matchRisk
|
||||||
|
test('security and data: paths and lines, each with a negative', () => {
|
||||||
|
expect(risk(['src/core/trust/owner-actions.ts'])).toEqual(['security']);
|
||||||
|
expect(risk(['lib/redact-patterns.ts'])).toEqual(['security']);
|
||||||
|
expect(risk(['src/run.ts'], { 'src/run.ts': ['const child = spawn(cmd, { shell: true });'] })).toEqual(['security']);
|
||||||
|
expect(risk(['src/view.tsx'], { 'src/view.tsx': ['<div dangerouslySetInnerHTML={{ __html: body }} />'] })).toEqual(['security']);
|
||||||
|
expect(risk(['bin/x.sh'], { 'bin/x.sh': ['eval "$CMD"'] })).toEqual(['security']);
|
||||||
|
expect(risk(['src/run.ts'], { 'src/run.ts': ['const m = re.exec(line);', 'const s = trustee.name;'] })).toEqual([]);
|
||||||
|
expect(risk(['src/core/persistence/git-door.ts'])).toEqual(['data']);
|
||||||
|
expect(risk(['src/db/users.ts'], { 'src/db/users.ts': ['await sql`DELETE FROM users WHERE id = ${id}`;'] })).toEqual(['data']);
|
||||||
|
expect(risk(['app/models/user.rb'], { 'app/models/user.rb': ['User.where(stale: true).destroy_all'] })).toEqual(['data']);
|
||||||
|
expect(risk(['src/list.ts'], { 'src/list.ts': ['// delete from the list when done', 'arr.splice(i, 1);'] })).toEqual([]);
|
||||||
|
for (const line of ['db.exec("delete from users");', 'await sql`delete from sessions where id = 1`;', 'DELETE FROM users;', 'drop database app;', 'DROP SCHEMA s;',
|
||||||
|
'await sql`DELETE FROM ${table} WHERE id = ?`;', 'DELETE FROM users u WHERE u.stale', 'DELETE FROM sessions USING users WHERE x', 'DELETE FROM users RETURNING id', 'const q = "DELETE FROM " + table;'])
|
||||||
|
expect(risk(['src/db.ts'], { 'src/db.ts': [line] })).toEqual(['data']);
|
||||||
|
for (const line of ["const r = spawn('sh', ['-c', cmd], { timeout: 1 });", "Bun.spawn(['bash', '-lc', x]);", "fs.rmSync(t, { recursive: true, force: true });", 'rm -rf "$DIR"'])
|
||||||
|
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['security']);
|
||||||
|
expect(risk(['scripts/test-pr-profile.ts', 'lib/storyboard.ts'])).toEqual([]);
|
||||||
|
expect(RISK_RULES).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
// Value: protects=the targeted pass keeps its exact classes now that its matchers live in lib/review-risk.ts; fails_when=the move
|
||||||
|
// changes a money, auth or secret verdict; seam=targetedClasses over matchRisk
|
||||||
|
test('money, auth and secret come from the same matchers the targeted pass reads', () => {
|
||||||
|
const lines = { 'src/run.ts': ['const spend_cap_usd = 5;', 'const k = process.env.OPENAI_API_KEY;'] };
|
||||||
|
expect(risk(['src/run.ts', 'src/auth/session.ts'], lines, true)).toEqual(['money', 'auth', 'secret']);
|
||||||
|
expect(targetedClasses({ paths: ['src/run.ts', 'src/auth/session.ts'], added: lines, removed: {}, scope: { SCOPE_AUTH: 'true' }, gateTagged: 0 }).classes).toEqual(['money', 'auth', 'secret']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UI text and SVGs stay code (R51: the docs paths are deferred to an allowlist classifier)', () => {
|
||||||
|
// Value: protects=a copy edit, a Vue binding or a namespaced SVG script never plans as docs; fails_when=any of them leaves the code
|
||||||
|
// class or its lines skip the risk scan; seam=classifyDiff
|
||||||
|
test.each([
|
||||||
|
['a JSX copy edit', 'src/App.tsx', ['<h1>Old copy</h1>'], ['<h1>New copy</h1>']],
|
||||||
|
['a Vue binding', 'src/App.vue', ['<div :title="user.name">Profile</div>'], ['<div :title="leak(document.cookie)">Profile</div>']],
|
||||||
|
['a locale value', 'locales/en.yml', ['save: "Save"'], ['save: "<img src=x onerror=alert(1)>"']],
|
||||||
|
['a namespaced SVG script', 'docs/chart.svg', [], ['<svg xmlns:s="http://www.w3.org/2000/svg"><s:script>window.x=42</s:script></svg>']],
|
||||||
|
])('%s is code', (_n, p, removed, added) => {
|
||||||
|
expect(classifyDiff({ paths: [p], gateTagged: 0, added: { [p]: added }, removed: { [p]: removed }, perPath: [{ path: p, lines: removed.length + added.length, binary: false }] }).class).toBe('code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round 2 never ships an unread gate edit', () => {
|
||||||
|
// Value: protects=a gate-class edit after round 1 always gets the owner's eyes; fails_when=a round-2 or fix-check gate edit
|
||||||
|
// returns done; seam=nextStep
|
||||||
|
test('a gate edit in round 2 or in the fix-check stops for the owner; a plain below-floor fix there finishes', () => {
|
||||||
|
expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true, touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') });
|
||||||
|
expect(nextStep({ fullPasses: 1, last: 'fix-check', touchedGate: true })).toMatchObject({ step: 'stop', reason: expect.stringContaining('gate-class') });
|
||||||
|
expect(nextStep({ fullPasses: 2, last: 'full', fixedBelow: true }).step).toBe('done');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const none = scanNumbers({});
|
||||||
|
const plan = (o: Partial<PlanInput>) => planReview({ paths: ['src/app.ts'], scope: { SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 10, numbers: none, history: { fullPasses: 0, last: 'none' }, ...o });
|
||||||
|
const roles = (p: ReturnType<typeof planReview>) => [...new Set(p.dispatches.map(d => d.role))];
|
||||||
|
const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice'];
|
||||||
|
const captured = (name: string): Partial<PlanInput> => {
|
||||||
|
const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-depth', name + '.json'), 'utf8'));
|
||||||
|
return { paths: x.paths, deleted: x.deleted, stampOnly: x.stamp_only ?? [], scope: x.scope, gateTagged: x.gate_tagged, diffLines: x.diff_lines, numbers: x.numbers, perPath: x.per_path };
|
||||||
|
};
|
||||||
|
const one = (p: string, lines: string[] = ['export const a = 1;']) => ({ paths: [p], added: { [p]: lines }, removed: {}, perPath: [{ path: p, lines: lines.length, binary: false }] });
|
||||||
|
|
||||||
|
describe('routing: every gate, security, user-data and money fixture plans the full panel', () => {
|
||||||
|
// Value: protects=R51 never lowers review on risky code; fails_when=a gate or risk fixture plans less than outside voice, Red Team,
|
||||||
|
// batched specialist and the native adversarial pass, or loses the targeted reader it had; seam=planReview
|
||||||
|
test.each([
|
||||||
|
['captured gate fixture', captured('code'), []],
|
||||||
|
['gate path', one('.github/workflows/ci.yml', ['on: push']), []],
|
||||||
|
['gate tag', { ...one('src/a.ts'), gateTagged: 2 }, ['gate']],
|
||||||
|
['deleted test', { ...one('src/a.ts'), deleted: ['test/a.test.ts'] }, []],
|
||||||
|
['auth scope', { ...one('src/auth/session.ts'), scope: { SCOPE_AUTH: 'true' } }, ['auth']],
|
||||||
|
['migration scope', { ...one('db/migrate/001.sql', ['ALTER TABLE users ADD COLUMN email text;']), scope: { SCOPE_MIGRATIONS: 'true' } }, []],
|
||||||
|
['secret path', one('lib/credentials.ts'), ['secret']],
|
||||||
|
['secret line', one('src/client.ts', ['const k = process.env.OPENAI_API_KEY;']), ['secret']],
|
||||||
|
['money path', one('src/billing/invoice.ts'), ['money']],
|
||||||
|
['money line', one('src/charge.ts', ['export const spend_cap_usd = 5;']), ['money']],
|
||||||
|
['security path', one('lib/redact-patterns.ts'), []],
|
||||||
|
['security line', one('src/run.ts', ['const run = new Function(body);']), []],
|
||||||
|
['user-data path', one('src/core/persistence/store.ts'), []],
|
||||||
|
['user-data line', one('src/db/purge.ts', ['await db.users.deleteMany({ where: { stale: true } });']), []],
|
||||||
|
] as const)('%s', (_n, o, targeted) => {
|
||||||
|
const p = plan({ ...(o as Partial<PlanInput>), targeted: { classes: [...targeted] } });
|
||||||
|
expect(p.depth).toBe('full');
|
||||||
|
expect(FULL_PANEL.filter(r => !roles(p).includes(r as never))).toEqual([]);
|
||||||
|
expect(roles(p).includes('targeted')).toBe(targeted.length > 0);
|
||||||
|
});
|
||||||
|
test('non-risky code is light at any size: one batched reader with the Red Team prompt and the native adversarial', () => {
|
||||||
|
const p = plan({ ...one('src/util.ts', Array.from({ length: 900 }, (_, n) => `export const v${n} = ${n};`)), diffLines: 900 });
|
||||||
|
expect([p.depth, roles(p)]).toEqual(['light', ['specialist', 'adversarial-native']]);
|
||||||
|
const docs = plan({ paths: ['docs/evals/run.md'], perPath: [{ path: 'docs/evals/run.md', lines: 400, binary: false }], numbers: scanNumbers({ 'docs/evals/run.md': ['recall 33/41'] }) });
|
||||||
|
expect([docs.depth, roles(docs)]).toEqual(['light', ['outside-voice']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the receipt records the tier and the rounds', () => {
|
||||||
|
// Value: protects=a receipt carries review_loop.rounds and the summary line prints depth and rounds; fails_when=the parser drops
|
||||||
|
// rounds or a legacy loop without it gains one; seam=parseReviewLoop and receiptSummaryLine
|
||||||
|
test('rounds parse and print; a legacy loop prints no rounds', () => {
|
||||||
|
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 })).toMatchObject({ rounds: 2 });
|
||||||
|
const r = { pr: 7, head: 'a'.repeat(40), base: 'main', review_depth: { depth: 'light' }, review_loop: { passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, rounds: 2 } } as never;
|
||||||
|
expect(receiptSummaryLine(r)).toContain('depth=light passes=1 blocking_found=0 deferred=0 fix_checks=1 rounds=2');
|
||||||
|
const legacy = { pr: 7, head: 'a'.repeat(40), base: 'main', review_loop: { passes: 2, blocking_found: 1, deferred: 0 } } as never;
|
||||||
|
expect(receiptSummaryLine(legacy)).not.toContain('rounds=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ describe('late defers from Step 11', () => {
|
|||||||
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] },
|
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:25:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true, action: 'deferred' }] },
|
||||||
];
|
];
|
||||||
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
||||||
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen' });
|
expect(s).toEqual({ passes: 2, blocking_found: 3, deferred: 2, noted: 0, fixed: 1, scope: 'frozen', rounds: 2 });
|
||||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen');
|
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=2 noted=0 fixed=1 scope=frozen');
|
||||||
expect(LATE_DEFER_SKILL).toBe('review-late-defer');
|
expect(LATE_DEFER_SKILL).toBe('review-late-defer');
|
||||||
// A `review` row logged without a start token would be the newest review row and read UNVERIFIED; the late-defer row is not a diff review at all.
|
// A `review` row logged without a start token would be the newest review row and read UNVERIFIED; the late-defer row is not a diff review at all.
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ describe('loop summary', () => {
|
|||||||
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] },
|
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] },
|
||||||
];
|
];
|
||||||
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
|
||||||
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' });
|
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 });
|
||||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
|
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ describe('loop summary', () => {
|
|||||||
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
|
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
|
||||||
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
|
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
|
||||||
];
|
];
|
||||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen' });
|
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,7 +421,7 @@ describe('gstack-review-scope (bin)', () => {
|
|||||||
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
|
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
|
||||||
];
|
];
|
||||||
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
|
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
|
||||||
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' };
|
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 };
|
||||||
const out = path.join(repo.root, 'review-loop.json');
|
const out = path.join(repo.root, 'review-loop.json');
|
||||||
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
|
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
|
||||||
expect(r.status).toBe(EXIT.ok);
|
expect(r.status).toBe(EXIT.ok);
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ describe('planReview dispatches the targeted reader', () => {
|
|||||||
test('at light, standard and full depth when a class hit, never when none did', () => {
|
test('at light, standard and full depth when a class hit, never when none did', () => {
|
||||||
expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' });
|
expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' });
|
||||||
expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full');
|
expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full');
|
||||||
expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }).depth).toBe('standard');
|
expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }).depth).toBe('standard');
|
||||||
expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }))).toContain('targeted full');
|
expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] }, floor: 'standard' }))).toContain('targeted full');
|
||||||
expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full');
|
expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full');
|
||||||
expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full');
|
expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full');
|
||||||
expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full');
|
expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full');
|
||||||
@@ -183,6 +183,18 @@ function planRepo(f: Fixture) {
|
|||||||
const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } });
|
const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } });
|
||||||
return { dir, run, write };
|
return { dir, run, write };
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* R51 (risk, not size): the depth each fixture plans now. Every risk or gate fixture plans full and keeps every role main
|
||||||
|
* dispatched, plus the full panel; a non-risky fixture keeps main's reviewers except the roles listed (size no longer raises it).
|
||||||
|
*/
|
||||||
|
const FULL_PANEL = ['specialist', 'red-team', 'adversarial-native', 'outside-voice'];
|
||||||
|
const R51: Record<string, { depth: string; panel: boolean; dropped?: string[] }> = {
|
||||||
|
'none-light': { depth: 'light', panel: false }, 'none-standard': { depth: 'light', panel: false, dropped: ['outside-voice'] }, 'none-docs': { depth: 'light', panel: false },
|
||||||
|
'none-tests': { depth: 'light', panel: false }, 'none-costume': { depth: 'light', panel: false }, 'none-gate-path': { depth: 'full', panel: true },
|
||||||
|
'secret-light': { depth: 'full', panel: true }, 'secret-path': { depth: 'full', panel: true }, 'secret-removed': { depth: 'full', panel: true }, 'gate-tagged': { depth: 'full', panel: true },
|
||||||
|
'money-light': { depth: 'full', panel: true }, 'money-path': { depth: 'full', panel: true }, 'money-removed': { depth: 'full', panel: true }, 'money-standard': { depth: 'full', panel: true },
|
||||||
|
'auth-path': { depth: 'full', panel: true }, 'money-fix-check': { depth: 'full', panel: false }, 'money-pass2': { depth: 'full', panel: false }, 'gate-repeat': { depth: 'full', panel: false },
|
||||||
|
};
|
||||||
const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1];
|
const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1];
|
||||||
|
|
||||||
describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => {
|
describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => {
|
||||||
@@ -190,17 +202,23 @@ describe('gstack-review-plan prints TARGETED and dispatches the targeted reader'
|
|||||||
// standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the
|
// standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the
|
||||||
// targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff
|
// targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff
|
||||||
// with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question)
|
// with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question)
|
||||||
test('the dispatch-superset no-loss check: on every fixture the branch dispatches main\'s reviewers plus only the targeted reader', () => {
|
// R51 changes the table: risk and gate fixtures plan full (every role main dispatched, plus the full panel), and the one
|
||||||
|
// non-risky standard fixture drops only the outside voice; fails_when=a risk fixture loses a role main dispatched or misses
|
||||||
|
// the full panel, or a non-risky fixture loses a role the R51 table does not list
|
||||||
|
test('the dispatch-superset no-loss check: risk and gate fixtures keep every role main dispatched plus the full panel; non-risky ones drop only what R51 lists', () => {
|
||||||
expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort());
|
expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort());
|
||||||
expect(new Set(Object.values(MAIN).map(m => m.depth))).toEqual(new Set(['light', 'standard', 'full']));
|
expect(Object.keys(R51).sort()).toEqual(Object.keys(FIXTURES).sort());
|
||||||
|
const role = (d: string) => d.split(' ')[0]!;
|
||||||
for (const [name, f] of Object.entries(FIXTURES)) {
|
for (const [name, f] of Object.entries(FIXTURES)) {
|
||||||
const r = planRepo(f).run(f.args ?? []);
|
const r = planRepo(f).run(f.args ?? []);
|
||||||
expect(r.status, `${name}: ${r.stderr}`).toBe(0);
|
expect(r.status, `${name}: ${r.stderr}`).toBe(0);
|
||||||
const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length));
|
const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length));
|
||||||
const want = MAIN[name]!;
|
const want = MAIN[name]!, now = R51[name]!;
|
||||||
expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${want.depth} `);
|
expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${now.depth} `);
|
||||||
expect(got.filter(d => !want.main.includes(d)), name).toEqual(want.classes === 'none' ? [] : ['targeted targeted effort=full']);
|
const roles = new Set(got.map(role));
|
||||||
expect(want.main.filter(d => !got.includes(d)), name).toEqual([]);
|
expect([...new Set(want.main.map(role))].filter(x => !roles.has(x)), name).toEqual(now.dropped ?? []);
|
||||||
|
if (now.panel) expect(FULL_PANEL.filter(x => !roles.has(x)), name).toEqual([]);
|
||||||
|
expect(roles.has('targeted'), name).toBe(want.classes !== 'none');
|
||||||
expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes);
|
expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes);
|
||||||
}
|
}
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ posixTest('a scripted actor that fixes the diff, defers the pre-existing defect
|
|||||||
await act(f);
|
await act(f);
|
||||||
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
const verdict = await shipFrozenScopeFailures(f, { exitReason: 'success' });
|
||||||
expect(verdict.failures).toEqual([]);
|
expect(verdict.failures).toEqual([]);
|
||||||
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, scope: 'frozen' });
|
expect(verdict.evidence.loop).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 0, fixed: 1, rounds: 2, scope: 'frozen' });
|
||||||
const rows = verdict.evidence.rows as Array<Record<string, any>>;
|
const rows = verdict.evidence.rows as Array<Record<string, any>>;
|
||||||
expect(rows.map(r => r.pass)).toEqual([1, 2]);
|
expect(rows.map(r => r.pass)).toEqual([1, 2]);
|
||||||
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
|
expect(rows[0]!.findings.map((x: any) => [x.fingerprint, x.action])).toEqual([[FROZEN_SCOPE_FIX_IDENTITY, 'auto-fixed'], [FROZEN_SCOPE_DEFER_IDENTITY, 'deferred']]);
|
||||||
|
|||||||
@@ -57,25 +57,26 @@ describe('write records the review depth at the checked-out head', () => {
|
|||||||
describe('the queue refuses a receipt below the depth its head needs', () => {
|
describe('the queue refuses a receipt below the depth its head needs', () => {
|
||||||
// Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do;
|
// Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do;
|
||||||
// fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt
|
// fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt
|
||||||
test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => {
|
// R51: size never raises the depth, so the head that needs more than light is a risk-class one (a money path needs full).
|
||||||
|
test('standard on a full (money) head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => {
|
||||||
const repo = makeFixtureRepo(); repos.push(repo);
|
const repo = makeFixtureRepo(); repos.push(repo);
|
||||||
const head = commit(repo, { 'lib/big.js': lines(80) });
|
const head = commit(repo, { 'lib/billing.js': lines(80) });
|
||||||
const body = path.join(repo.root, 'body.md');
|
const body = path.join(repo.root, 'body.md');
|
||||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 });
|
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', runtime_lines: 80, risk: ['money'] });
|
||||||
const base = repo.git('rev-parse', 'origin/main').slice(0, 12);
|
const base = repo.git('rev-parse', 'origin/main').slice(0, 12);
|
||||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||||
const good = bind(fs.readFileSync(body, 'utf8'));
|
const good = bind(fs.readFileSync(body, 'utf8'));
|
||||||
expect(good.ok).toBe(true);
|
expect(good.ok).toBe(true);
|
||||||
expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code');
|
expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=full required=full runtime_lines=80 class=code');
|
||||||
|
|
||||||
const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
const low = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; }));
|
||||||
expect(low.ok).toBe(false);
|
expect(low.ok).toBe(false);
|
||||||
if (low.ok) return;
|
if (low.ok) return;
|
||||||
expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||||
expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80');
|
expect(low.depthLine).toContain('receipt=standard required=full runtime_lines=80');
|
||||||
expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`);
|
expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`);
|
||||||
expect(low.message).toContain('runtime=80>50');
|
expect(low.message).toContain('risk:money');
|
||||||
expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`);
|
expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`);
|
||||||
|
|
||||||
const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); }));
|
const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); }));
|
||||||
@@ -218,7 +219,8 @@ describe('the queue and a scoped second pass (GSTA-152 D6)', () => {
|
|||||||
expect(receiptIn(body).review_loop).toMatchObject({ passes: 2, dispatches: loop.dispatches });
|
expect(receiptIn(body).review_loop).toMatchObject({ passes: 2, dispatches: loop.dispatches });
|
||||||
const bound = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]);
|
const bound = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]);
|
||||||
expect(bound.ok).toBe(true);
|
expect(bound.ok).toBe(true);
|
||||||
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard');
|
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=light required=light');
|
||||||
|
expect(bound.ok && bound.depthLine).toMatch(/ rounds=2$/);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user