mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
train S2: merge #3926 at 1b31842a2d v1.95.1.0
This commit is contained in:
commit
37ea27ec78
42 files changed
+801
-93
No files matched your search
@@ -144,7 +144,7 @@ describe('pause, receipt and wait', () => {
|
||||
fs.writeFileSync(bodyFile, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any));
|
||||
const res = bin('gstack-ship-receipt', ['result', '--body', bodyFile, '--head', head, '--out', path.join(root, 'run')]);
|
||||
expect(res.status).toBe(EXIT.fail);
|
||||
expect(res.stdout.trim()).toBe(`GSTACK_RESULT: skill=ship status=incomplete run=${path.join(root, 'run')}`);
|
||||
expect(res.stdout.trim()).toBe(`GSTACK_RESULT: skill=ship status=incomplete head=${head} run=${path.join(root, 'run')}`);
|
||||
expect(res.stderr).toContain(`SHIP_PAUSED: base-red cases=1 base=${pause.base_sha.slice(0, 12)} lane=tests`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(root, 'run', 'ship-run.json'), 'utf8')).status).toBe('incomplete');
|
||||
const carry = bin('gstack-ship-receipt', ['carry', '--body', bodyFile]);
|
||||
|
||||
+3
-3
@@ -956,9 +956,9 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
It binds the receipt to HEAD, writes the run record, prints `GSTACK_RESULT: skill=ship
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE`: HEAD moved past the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs early ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -955,9 +955,9 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
It binds the receipt to HEAD, writes the run record, prints `GSTACK_RESULT: skill=ship
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE`: HEAD moved past the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs early ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -965,9 +965,9 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
$GSTACK_ROOT/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
It binds the receipt to HEAD, writes the run record, prints `GSTACK_RESULT: skill=ship
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE`: HEAD moved past the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs early ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -967,9 +967,9 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
$GSTACK_ROOT/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
It binds the receipt to HEAD, writes the run record, prints `GSTACK_RESULT: skill=ship
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE`: HEAD moved past the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs early ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"sha":"25c09be7ea5c0e438ee059af2064f5bf038f6b69e3b5378f6c47be669f7ecd17","replay":"GSTA-258-C"}
|
||||
{"sha":"5edd49d21b5acc4a92001c2d8ed0768917bdc2d278b209b322359607ac2058d8","replay":"GSTA-258-C+GSTA-258-L4"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GST-508+GST-464+GST-967+GSTA-258-L4","justification":"GSTA-258 L4 (GST-1168) brief-identity no-loss check (the brief-body test below): ship/sections/review-army.md is the GST-508+GST-464+GST-967 bytes plus Step 9.4 lead instructions outside the cut spans (a finding's ledger_id, the Review-Finding fix-commit trailer, logging a late defer when it is decided); every reviewer brief lib/review-brief.ts cuts from it still hashes to BRIEF_BODIES, so no reviewer sees a changed byte"}
|
||||
@@ -0,0 +1 @@
|
||||
{"replay":"GSTA-258-C+GSTA-258-L4","justification":"merge of main bf2cab34c into #3926 under a rule 19 grant: ship/sections/review-army.md is main's GSTA-258-C bytes plus exactly the GSTA-258-L4 lines (the merged file minus main equals the branch minus its merge base 1ccab0d8b, line for line; the two edits touch different paragraphs and merged without a conflict), so GSTA-258-C's planner no-loss check and GSTA-258-L4's brief-identity no-loss check both hold; every reviewer brief lib/review-brief.ts cuts from it still hashes to BRIEF_BODIES"}
|
||||
@@ -49,7 +49,7 @@ describe('E18: a repository without a version source or CHANGELOG', () => {
|
||||
const out = path.join(repo.root, 'run');
|
||||
const result = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out]);
|
||||
expect(result.status).toBe(EXIT.ok);
|
||||
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${out}`);
|
||||
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${out}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
|
||||
});
|
||||
|
||||
|
||||
@@ -165,9 +165,10 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const last = ok.out.trim().split('\n').at(-1)!;
|
||||
const m = /^GSTACK_RESULT: skill=ship status=complete run=(.+)$/.exec(last);
|
||||
const m = /^GSTACK_RESULT: skill=ship status=complete head=([0-9a-f]{40}) run=(.+)$/.exec(last);
|
||||
expect(m).not.toBeNull();
|
||||
const dir = m![1]!;
|
||||
expect(m![1]).toBe(head);
|
||||
const dir = m![2]!;
|
||||
expect(dir.startsWith(path.join(state, 'ship', 'runs'))).toBe(true);
|
||||
expect(dir.endsWith(`-${head.slice(0, 12)}`)).toBe(true);
|
||||
const run = JSON.parse(fs.readFileSync(path.join(dir, 'ship-run.json'), 'utf8'));
|
||||
@@ -202,13 +203,13 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
fs.writeFileSync(bodyFile, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
|
||||
const handoff = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(repo.root, 'run-out')], env);
|
||||
expect(handoff.status).toBe(EXIT.ok);
|
||||
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'run-out')}`);
|
||||
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.root, 'run-out')}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
|
||||
const relative = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', 'rel-out'], env);
|
||||
expect(relative.status).toBe(EXIT.ok);
|
||||
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.work, 'rel-out')}`);
|
||||
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.work, 'rel-out')}`);
|
||||
expect(fs.existsSync(path.join(repo.work, 'rel-out', 'ship-run.json'))).toBe(true);
|
||||
const blocker = path.join(repo.root, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'file\n');
|
||||
@@ -251,7 +252,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
const pinned = path.join(dir, 'pinned.md');
|
||||
fs.writeFileSync(pinned, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
|
||||
const override = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'c'.repeat(40), '--out', path.join(repo.root, 'pinned-out')], env);
|
||||
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'pinned-out')}`]);
|
||||
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete head=${'c'.repeat(40)} run=${path.join(repo.root, 'pinned-out')}`]);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
|
||||
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
|
||||
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
|
||||
@@ -260,7 +261,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']);
|
||||
expect(shortHead.err).toContain('--head must be a full hex commit id');
|
||||
const upper = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'C'.repeat(40), '--out', path.join(repo.root, 'upper-out')], env);
|
||||
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'upper-out')}`]);
|
||||
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete head=${'c'.repeat(40)} run=${path.join(repo.root, 'upper-out')}`]);
|
||||
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
|
||||
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
|
||||
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
|
||||
@@ -312,7 +313,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(carried.out.trim().split('\n')).toEqual([
|
||||
`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change)`,
|
||||
`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'carry-run')}`,
|
||||
`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.root, 'carry-run')}`,
|
||||
]);
|
||||
expect(carried.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main passes=2 blocking_found=1 deferred=1 carried_from=${reviewed.slice(0, 12)}`);
|
||||
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
|
||||
|
||||
@@ -413,7 +413,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
expect(evalPlan).toContain('No helper writes a gate.json');
|
||||
expect(evalPlan).not.toContain('gate list');
|
||||
const ship = blocks.ship;
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=refused run=-`');
|
||||
expect(ship).not.toContain('writes no run directory');
|
||||
const records: Record<string, string> = {
|
||||
@@ -437,7 +437,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' });
|
||||
expect(ship.match(line)).toHaveLength(1);
|
||||
expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']);
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`');
|
||||
expect(ship).toContain("update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never `gh pr ready`");
|
||||
expect(ship).toContain('the handoff section writes the receipt to .gstack/tmp/ship-receipt.md');
|
||||
for (const skill of ['review', 'qa', 'autoplan']) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GSTA-258 L4 addendum: the collector raised the native reader's own verdict line to CRITICAL. On #3901 (pass 2, gstack 1.94
|
||||
* branch) "There are three low-severity findings, all FIXABLE, and none blocks." collected as a blocker beside the three
|
||||
* findings it summarizes, each of which already said "Severity: Low. Blocks: no". Excerpt of that captured output below.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseFindings } from '../lib/review-ledger';
|
||||
|
||||
const CAPTURED_3901_P2 = [
|
||||
'REVIEWED_TREE: 7be72149fbd6d1b8e713c7c42ef4cd9ba6373e20 /workspace/gstack',
|
||||
'',
|
||||
'I found no blocking defects in this range. There are three low-severity findings, all FIXABLE, and none blocks.',
|
||||
'',
|
||||
'### Findings',
|
||||
'',
|
||||
'1. **The remote-gate route hint ignores `--no-ci-carry`** (`lib/pregate/checks/lanes.ts`, the `route` condition). The hint prints whenever `remote_gate_runs` is declared. FIXABLE. Severity: Low. Blocks: no.',
|
||||
'',
|
||||
'Recommendation: Ship as-is.',
|
||||
].join('\n');
|
||||
|
||||
const labels = (out: string, reader = 'adversarial-native') => parseFindings(reader, out, true).map(f => f.label);
|
||||
|
||||
describe('native verdict line (collector severity inflation)', () => {
|
||||
test('the captured #3901 verdict line stays FIXABLE; the finding keeps its own Severity: Low', () => {
|
||||
expect(labels(CAPTURED_3901_P2)).toEqual(['FIXABLE', 'Low']);
|
||||
});
|
||||
|
||||
test('negative controls: a line that says something blocks, names a file:line, or leads with FIXABLE is still CRITICAL', () => {
|
||||
expect(labels('Three findings are FIXABLE, and one blocks.')).toEqual(['CRITICAL']);
|
||||
expect(labels('The token leak in log.ts:3 is FIXABLE, and none of the others blocks.')).toEqual(['CRITICAL']);
|
||||
expect(labels('FIXABLE: none blocks the merge but the cache key drops the tree.')).toEqual(['CRITICAL']);
|
||||
expect(labels('- FIXABLE, and nothing blocks')).toEqual(['CRITICAL']);
|
||||
});
|
||||
|
||||
// ggstack#71 (GGST-15, gstack 1.94.4.0, gate-class plan): four identically shaped native findings, the first labeled CRITICAL
|
||||
// because its Severity field followed a closing quote ('… its base." Severity: Low'). Excerpt of findings 1 and 2, as returned.
|
||||
test('ggstack#71: a Severity field after a closing quote is still the finding\'s own field', () => {
|
||||
const out = ['## Findings', '', "**1. The CHANGELOG describes the \"behind main\" rule wrongly (CHANGELOG.md:10).** It says \"a pull request that doesn't contain main's tip runs the suite here as before.\" gstack's actual rule is different: the run's merge-commit tree must equal the head's tree. That means the head had to contain its base as it was when CI ran, and that base isn't always main (stacked PRs). Nothing in `findCarry` or `verifyRun` compares against main's current tip, so a run from before main moved still carries. /ship's own merge of the base usually changes the tree before this matters, but the text states a guarantee the code doesn't make. FIXABLE: reword it to something like \"a pull_request run made while the branch was behind its base.\" Severity: Low. Blocks: no", '', "**2. `files` is a static file listing, and run.sh skips Godot silently (test.yml:46, pregate.json:7).** tree.json's `files` comes from the same `ls -1 test/*.sh test/*.py test/*.gd` as the inventory, so the coverage check passes by construction. The receipt's `tests=9` counts files, not tests. run.sh prints SKIP and exits 0 when `$GODOT` isn't executable or neither `xvfb-run` nor `DISPLAY` is available. Web has `REQUIRE_WEB`, but nothing equivalent makes a missing Godot fail. Today the Godot and xvfb install steps fail hard, so nothing falsely carries. If a later workflow edit makes either step soft, CI would still list the `.gd` tests as covered. A local run without Godot skips them the same way, so the carry is never weaker than the lane it replaces. FIXABLE: add `REQUIRE_GODOT=1`, or fail the record step when the run.sh output contains `test: SKIP`. Severity: Low. Blocks: no", '', 'Recommendation: Ship as-is.'].join('\n');
|
||||
expect(labels(out)).toEqual(['Low', 'Low']);
|
||||
expect(labels('1. **Wording (a.md:3).** FIXABLE: say \'the base\'. Severity: Low. Blocks: no')).toEqual(['Low']);
|
||||
expect(labels('1. **Wording (a.md:3).** FIXABLE: say \u201cthe base.\u201d Severity: Low. Blocks: no')).toEqual(['Low']);
|
||||
expect(labels('1. **Data loss (a.ts:3).** FIXABLE: guard it." Severity: Low. Blocks: yes')).toEqual(['CRITICAL']);
|
||||
});
|
||||
|
||||
test('a stated Blocks field on the line wins: Blocks: yes stays CRITICAL', () => {
|
||||
expect(labels('All FIXABLE, none blocks. Blocks: yes')).toEqual(['CRITICAL']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* GST-1168: the receipt's review counts bind by finding id. Each case is a shape a real /ship run produced
|
||||
* (GBEV-3 gbrain-evals#310, GEVA-223 gstack-evals#174, #3906 at 9417c7d, #3892/#3903/#3911); before the fix
|
||||
* the summary paired rows and ledgers by path:line only and dropped rows it could not place without a word.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseFixTrailers, reviewLoopLine, summarizeReviewLoop, type LedgerPass } from '../lib/review-scope';
|
||||
import type { LedgerBlocker } from '../lib/review-ledger';
|
||||
|
||||
const SINCE = '2026-11-02T10:00:00.000Z';
|
||||
const at = (min: number) => new Date(Date.parse(SINCE) + min * 60_000).toISOString();
|
||||
const row = (skill: string, min: number, findings: Record<string, unknown>[], extra: Record<string, unknown> = {}) =>
|
||||
({ skill, via: 'ship', review_scope: 'frozen', review_binding: { started_at: at(min) }, findings, ...extra });
|
||||
const blocker = (pass: string, id: string, locus: string | null): LedgerBlocker => ({ id, locus, key: `${pass}/${id}` });
|
||||
const pass = (min: number, ids: string[], fixCheck = false): LedgerPass => ({ started: Date.parse(at(min)), fixCheck, ids });
|
||||
|
||||
describe('review loop counts bind by finding id (GST-1168)', () => {
|
||||
test('GBEV-3: a deferred finding logged late counts once and a fix-check never marks it fixed', () => {
|
||||
const rows = [
|
||||
row('review', 1, [{ fingerprint: 'src/a.ts:10:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'adversarial-native', ledger_id: 'p1/adversarial-native#1' }]),
|
||||
row('review-late-defer', 30, [{ fingerprint: 'src/b.ts:20:security', severity: 'CRITICAL', action: 'deferred', issue: 311, blocking: true, raised_by: 'adversarial-native', ledger_id: 'p1/adversarial-native#2' }]),
|
||||
];
|
||||
const ledgers = [[blocker('p1', 'adversarial-native#1', 'src/a.ts:10'), blocker('p1', 'adversarial-native#2', 'src/b.ts:21')], []];
|
||||
const s = summarizeReviewLoop(rows, SINCE, ledgers, [pass(0, ['p1/adversarial-native#1', 'p1/adversarial-native#2']), pass(20, ['fc/fix-check#1'], true)]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, deferred: s.deferred, unrecorded: s.unrecorded }).toEqual({ found: 2, fixed: 1, deferred: 1, unrecorded: undefined });
|
||||
});
|
||||
|
||||
test('GEVA-223: native findings with no path:line pair with their rows by ledger_id', () => {
|
||||
const ids = Array.from({ length: 9 }, (_, i) => `adversarial-native#${i + 1}`);
|
||||
const findings = [
|
||||
...ids.map((id, i) => ({ fingerprint: `lib/x.ts:${10 * (i + 1)}:correctness`, severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'adversarial-native', ledger_id: `p1/${id}` })),
|
||||
...Array.from({ length: 6 }, (_, i) => ({ fingerprint: `lib/y.ts:${i + 1}:style`, severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by: 'specialist' })),
|
||||
];
|
||||
const s = summarizeReviewLoop([row('review', 1, findings)], SINCE, [ids.map(id => blocker('p1', id, null))], [pass(0, ids.map(id => `p1/${id}`))]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, noted: s.noted, unrecorded: s.unrecorded }).toEqual({ found: 9, fixed: 9, noted: 6, unrecorded: undefined });
|
||||
});
|
||||
|
||||
test('#3906: fixes named by Review-Finding trailers count even when no row recorded the findings', () => {
|
||||
const ledger = [1, 2, 3].map(n => blocker('p1', `red-team#${n}`, `lib/auth.ts:${25 + 15 * n}`));
|
||||
const trailers = parseFixTrailers('9417c7d0000000000000000000000000000000000', ['p1/red-team#1 reader=red-team', 'p1/red-team#2 reader=red-team', 'p1/red-team#3 reader=red-team']);
|
||||
const s = summarizeReviewLoop([row('review', 1, [])], SINCE, [ledger], [pass(0, ledger.map(b => b.key!))], trailers);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, unrecorded: s.unrecorded }).toEqual({ found: 3, fixed: 3, unrecorded: undefined });
|
||||
expect(reviewLoopLine(s)).toContain('fixed=3');
|
||||
});
|
||||
|
||||
test('#3892/#3903/#3911: a row with no start token and no readable time is named unbound, never dropped silently', () => {
|
||||
const tokenless = { skill: 'review', via: 'ship', pass: 2, timestamp: 'TIMESTAMP', findings: [{ fingerprint: 'lib/q.ts:7:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true }] };
|
||||
const s = summarizeReviewLoop([row('review', 1, []), tokenless], SINCE);
|
||||
expect(s.unbound).toEqual(['row review pass=2: no start token and no readable timestamp, so it cannot be placed in this run']);
|
||||
expect(reviewLoopLine(s)).toContain('unbound=1');
|
||||
});
|
||||
|
||||
test('a ledger_id no ledger collected and a trailer naming nothing are unbound with their names', () => {
|
||||
const rows = [row('review', 1, [{ fingerprint: 'lib/z.ts:3:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'specialist', ledger_id: 'p9/specialist#4' }])];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[]], [pass(0, [])], parseFixTrailers('abc1234def567', ['p1/targeted#2 reader=targeted', 'no-reader-field']));
|
||||
expect(s.unbound).toEqual([
|
||||
'ledger_id p9/specialist#4: no dispatch ledger of this run collected it',
|
||||
"trailer abc1234def56 Review-Finding: p1/targeted#2 reader=targeted: names no finding this run's rows or ledgers hold",
|
||||
]);
|
||||
});
|
||||
|
||||
test('#3911 at 4340ed4c: a fix-check row written twice counts once, and pass 1 with no row is named', () => {
|
||||
const fc = { skill: 'review-fix-check', via: 'ship', pass: 2, commit: '4340ed4c', review_binding: { started_at: at(40) }, findings: [{ fingerprint: 'lib/p.ts:9:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'fix-check', ledger_id: 'fc/fix-check#1' }] };
|
||||
const rows = [row('review', 20, [], { pass: 2, commit: '1111111' }), fc, { ...fc }];
|
||||
const passes = [{ ...pass(0, []), name: 'p1' }, { ...pass(20, []), name: 'p2' }, { ...pass(40, ['fc/fix-check#1'], true), name: 'fc' }];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[], [], [blocker('fc', 'fix-check#1', 'lib/p.ts:9')]], passes);
|
||||
expect({ passes: s.passes, fix_checks: s.fix_checks, found: s.blocking_found, fixed: s.fixed }).toEqual({ passes: 1, fix_checks: 1, found: 1, fixed: 1 });
|
||||
expect(s.unbound).toEqual(['pass 1 (ledger p1): no review-log row, so its fixes and dispositions cannot be counted']);
|
||||
});
|
||||
|
||||
test('a fingerprint trailer binds a finding that also carries a ledger_id (outside voice #2)', () => {
|
||||
const rows = [row('review', 1, [{ fingerprint: 'src/auth.ts:10:correctness', severity: 'CRITICAL', blocking: true, raised_by: 'red-team', ledger_id: 'p1/red-team#1' }])];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[blocker('p1', 'red-team#1', 'src/auth.ts:10')]], [pass(0, ['p1/red-team#1'])], parseFixTrailers('abc', ['src/auth.ts:10:correctness reader=red-team']));
|
||||
expect({ fixed: s.fixed, unrecorded: s.unrecorded, unbound: s.unbound }).toEqual({ fixed: 1, unrecorded: undefined, unbound: undefined });
|
||||
});
|
||||
|
||||
test('rows without ledger_id keep pairing by path:line, as before', () => {
|
||||
const rows = [row('review', 1, [{ fingerprint: 'src/a.ts:10:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'specialist' }])];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[blocker('p1', 'specialist/security#1', 'src/a.ts:10')]]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, unrecorded: s.unrecorded, unbound: s.unbound }).toEqual({ found: 1, fixed: 1, unrecorded: undefined, unbound: undefined });
|
||||
});
|
||||
});
|
||||
@@ -223,7 +223,8 @@ 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', 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', rounds: 3 });
|
||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3,
|
||||
unbound: ['row review pass=?: no start token and no readable timestamp, so it cannot be placed in this run'] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* GST-1179: gstack-ship-receipt's terminal line names the full head it bound, so a leftover line from an earlier ship can't
|
||||
* pass for a new head (harness-evals' merge lane requires it from 2026-11-09T08:00Z).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, parseResult } from '../lib/headless-artifacts';
|
||||
import { renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
describe('GSTACK_RESULT head= token (GST-1179)', () => {
|
||||
test('result prints head=<full sha> between status and run, and parseResult reads it', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'ship-result-head-' }); repos.push(repo);
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
|
||||
const out = path.join(repo.root, 'run');
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out], { GSTACK_STATE_ROOT: path.join(repo.root, 'state') });
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const line = r.out.trim().split('\n').at(-1)!;
|
||||
expect(line).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${out}`);
|
||||
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(parseResult(r.out)).toEqual({ skill: 'ship', status: 'complete', head, run: out });
|
||||
}, 30_000);
|
||||
|
||||
test('a line without head= still parses, with no head', () => {
|
||||
expect(parseResult('GSTACK_RESULT: skill=pregate status=complete run=.gstack/tmp/pregate.json')).toEqual({ skill: 'pregate', status: 'complete', run: '.gstack/tmp/pregate.json' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* GSTA-258 L4 receipt attribution: a PR body keeps every /ship run's receipt (append-only runs block), and the receipt
|
||||
* schema carries K1's no_stop records for L5.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { extractReceipt, orderReceipt, placeReceiptFirst, readyTailLines, receiptRuns, RECEIPT_RUNS_KEPT, validateReceipt } from '../lib/ship-receipt';
|
||||
import type { ShipReceipt } from '../lib/headless-artifacts';
|
||||
|
||||
const receipt = (head: string, loop: Partial<NonNullable<ShipReceipt['review_loop']>> = {}): ShipReceipt =>
|
||||
orderReceipt({ schema_version: 1, head, base: 'main', version: '1.94.1.0', review_loop: { passes: 1, blocking_found: 0, deferred: 0, ...loop } });
|
||||
|
||||
describe('receipt runs block', () => {
|
||||
test('a write over a different receipt keeps the old run, oldest first, after the current block', () => {
|
||||
let body = placeReceiptFirst('## Summary\nprose\n', receipt('a'.repeat(40), { passes: 2, blocking_found: 3, fixed: 3 }));
|
||||
body = placeReceiptFirst(body, receipt('b'.repeat(40)));
|
||||
body = placeReceiptFirst(body, receipt('c'.repeat(40), { passes: 1 }));
|
||||
expect(extractReceipt(body).receipt!.head).toBe('c'.repeat(40));
|
||||
expect(receiptRuns(body).map(r => [r.head[0], r.review_loop?.blocking_found])).toEqual([['a', 3], ['b', 0]]);
|
||||
expect(body.indexOf('gstack-receipt-runs')).toBeLessThan(body.indexOf('## Summary'));
|
||||
expect(body.match(/## Summary/g)!.length).toBe(1);
|
||||
});
|
||||
|
||||
test('a fence inside a recorded reason never closes the runs block (adversarial p-voices #1)', () => {
|
||||
const withReason = (head: string) => orderReceipt({ ...receipt(head, { fix_checks: 1 }), review_loop_exception: { accepted_by: 'Garry', date: '2026-11-03', via: 'x', reason: 'the \x60\x60\x60retry\x60\x60\x60 block is fine', fix_check: { head, findings: 0 } } });
|
||||
let body = placeReceiptFirst('## Summary\nprose\n', withReason('b'.repeat(40)));
|
||||
body = placeReceiptFirst(body, withReason('c'.repeat(40)));
|
||||
body = placeReceiptFirst(body, withReason('d'.repeat(40)));
|
||||
expect(receiptRuns(body).map(r => r.head[0])).toEqual(['b', 'c']);
|
||||
expect(body.endsWith('## Summary\nprose\n')).toBe(true);
|
||||
});
|
||||
|
||||
test('rewriting the same receipt adds no run, and the block keeps the newest RECEIPT_RUNS_KEPT', () => {
|
||||
const same = receipt('d'.repeat(40));
|
||||
expect(receiptRuns(placeReceiptFirst(placeReceiptFirst('', same), same))).toEqual([]);
|
||||
let body = '';
|
||||
for (let i = 0; i < RECEIPT_RUNS_KEPT + 5; i++) body = placeReceiptFirst(body, receipt(i.toString(16).padStart(40, '0')));
|
||||
const runs = receiptRuns(body);
|
||||
expect(runs.length).toBe(RECEIPT_RUNS_KEPT);
|
||||
expect(runs.at(-1)!.head).toBe((RECEIPT_RUNS_KEPT + 3).toString(16).padStart(40, '0'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('READY tail (E1: the ready output is the whole READY)', () => {
|
||||
const depth = (cls: 'docs' | 'code' | 'gate', risk?: Array<'money' | 'auth' | 'secret' | 'security' | 'data'>) =>
|
||||
({ depth: 'full' as const, class: cls, runtime_lines: 10, rules: 'r', base: 'b', registry: null, gate_scan: { tagged: 0, head: 'h' }, reasons: [], ...(risk ? { risk } : {}) });
|
||||
test('a risk-class receipt asks for the cross-family read; any other PR is self-audited', () => {
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('gate') })[3]).toStartWith('reviewed by <model on another family> (risk class: gate;');
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('code', ['money']) })[3]).toContain('risk class: money');
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('docs') })[3]).toBe('self-audited (not a risk class: no cross-family trail read)');
|
||||
});
|
||||
test('the review loop line carries fixed, unrecorded and unbound from the receipt', () => {
|
||||
const lines = readyTailLines(receipt('f'.repeat(40), { passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, unbound: 1 }));
|
||||
expect(lines[0]).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=0 noted=0 fixed=2 unrecorded=1 unbound=1');
|
||||
expect(lines.slice(1, 3)).toEqual([expect.stringMatching(/^TRIAL_MERGES: /), 'Attention:']);
|
||||
expect(readyTailLines(undefined)[0]).toBe('REVIEW_LOOP: none in the receipt');
|
||||
expect(readyTailLines(receipt('f'.repeat(40)))[3]).toStartWith('reviewed by <model on another family> (risk class unknown: the receipt records no review_depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no_stop receipt field (K1)', () => {
|
||||
test('a record per routine stop validates; an unknown class or a missing field does not', () => {
|
||||
const ok = orderReceipt({ ...receipt('e'.repeat(40)), no_stop: [{ class: 'pregate-flake-rerun', reason: 'one rerun of test/x.test.ts at the same tree', evidence: 'run 41562764513' }] });
|
||||
expect(validateReceipt(ok)).toEqual([]);
|
||||
expect(Object.keys(ok).at(-1)).toBe('no_stop');
|
||||
expect(validateReceipt({ ...ok, no_stop: [{ class: 'merge-anyway', reason: 'x', evidence: 'y' }] } as any).length).toBeGreaterThan(0);
|
||||
expect(validateReceipt({ ...ok, no_stop: [{ class: 'stamp-only-carry', reason: 'x' }] } as any).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* J1 (GSTA-258 L4): a scoped re-check after a refused receipt carry. Each refusal reason that does or does not qualify,
|
||||
* the record binding, and the carry end to end: a bounded product change prints RECHECK: eligible and writes the packet,
|
||||
* a clean record bound to that packet carries the receipt with the reader named, and a changed risk class, a legacy
|
||||
* receipt, more paths than the walk names and moved base context never take the re-check (the /ship p1 red-team and
|
||||
* security findings).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
|
||||
import { planRecheck, readRecheck, RECHECK_PACKET_BYTES, RECHECK_RANGE_DIFF_CAP } from '../lib/ship-recheck';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const RANGE = '1: aaaaaaa ! 1: bbbbbbb feat: a=2\n @@ src/app.ts\n -export const a = 2;\n +export const a = 3;';
|
||||
const OK = { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE };
|
||||
|
||||
describe('planRecheck: which refusals qualify', () => {
|
||||
test('a bounded product change on linear history against the same base qualifies', () => {
|
||||
expect(planRecheck(OK)).toMatchObject({ ok: true, lines: 4 });
|
||||
});
|
||||
test.each([
|
||||
['tree mismatch', { ...OK, code: 'RECEIPT_CARRY_TREE_MISMATCH', ancestor: false }, 'is not a product change'],
|
||||
['refused inputs', { ...OK, code: 'RECEIPT_CARRY_REFUSED' }, 'is not a product change'],
|
||||
['stale pregate', { ...OK, code: 'RECEIPT_CARRY_PREGATE_STALE' }, 'is not a product change'],
|
||||
['rewritten history', { ...OK, ancestor: false }, 'rewritten history'],
|
||||
['retargeted stack', { ...OK, retarget: true }, 'another base'],
|
||||
['legacy receipt', { ...OK, legacy: true }, 'no review_depth'],
|
||||
['walk cut at the named-path limit', { ...OK, walkCut: true }, 'more changed product paths'],
|
||||
['unnamed product path', { ...OK, unnamedPaths: true }, 'could not be named'],
|
||||
['base context moved', { ...OK, contextPaths: 1 }, 'base context'],
|
||||
['binary product path', { ...OK, binary: true }, 'binary'],
|
||||
['wide packet over the byte cap', { ...OK, rangeDiff: 'x'.repeat(RECHECK_PACKET_BYTES + 1) }, 'bytes'],
|
||||
['range-diff failed', { ...OK, rangeDiff: null }, 'failed or timed out'],
|
||||
['empty range-diff', { ...OK, rangeDiff: '\n' }, 'empty'],
|
||||
['over the cap', { ...OK, rangeDiff: Array.from({ length: RECHECK_RANGE_DIFF_CAP + 1 }, (_, i) => `l${i}`).join('\n') }, 'over the scoped re-check cap'],
|
||||
])('%s does not qualify', (_name, input, why) => {
|
||||
const p = planRecheck(input);
|
||||
expect(p.ok).toBe(false);
|
||||
expect(!p.ok && p.reason).toContain(why);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecheck binds to from, head and the range-diff', () => {
|
||||
test('only a clean record for this exact range-diff stands', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'recheck-'));
|
||||
const bind = { from: 'a'.repeat(40), head: 'b'.repeat(40), sha256: 'c'.repeat(64) };
|
||||
const write = (o: object) => { const f = path.join(dir, `${Math.random()}.json`); fs.writeFileSync(f, JSON.stringify(o)); return f; };
|
||||
const good = { from: bind.from, head: bind.head, range_diff_sha256: bind.sha256, reader: 'anthropic/claude-opus-5-5', verdict: 'clean' };
|
||||
expect(readRecheck(write(good), bind).ok).toBe(true);
|
||||
expect(readRecheck(write({ ...good, verdict: 'blocking' }), bind)).toMatchObject({ ok: false, message: expect.stringContaining('not clean') });
|
||||
expect(readRecheck(write({ ...good, range_diff_sha256: 'd'.repeat(64) }), bind)).toMatchObject({ ok: false, message: expect.stringContaining('range_diff_sha256') });
|
||||
expect(readRecheck(write({ ...good, reader: '' }), bind)).toMatchObject({ ok: false, message: 'the re-check names no reader' });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
/** A branch reviewed at one commit, with a receipt written by gstack-ship-receipt write (so it records review_depth). */
|
||||
function reviewed(files: Record<string, string> = { 'src/app.ts': 'export const a = 2;\n' }) {
|
||||
const repo = makeFixtureRepo({ prefix: 'ship-recheck-' }); repos.push(repo);
|
||||
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
for (const [f, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(repo.work, f), text); }
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'feat');
|
||||
const from = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--body', body], env);
|
||||
expect([w.status, w.err.slice(0, 300)]).toEqual([EXIT.ok, expect.any(String)]);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.review_depth).toBeDefined();
|
||||
const commit = (changes: Record<string, string>, msg: string) => {
|
||||
for (const [f, text] of Object.entries(changes)) { fs.mkdirSync(path.dirname(path.join(repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(repo.work, f), text); }
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', msg);
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
};
|
||||
return { repo, from, body, env, commit };
|
||||
}
|
||||
const eligible = (err: string) => /^RECHECK: eligible range_diff_lines=(\d+) range_diff_sha256=([0-9a-f]{64}) packet=(\S+);/m.exec(err);
|
||||
|
||||
describe('carry with a scoped re-check (CLI)', () => {
|
||||
test('a bounded product change prints RECHECK: eligible, keeps the refusal, and a clean record carries with the reader named', () => {
|
||||
const { repo, from, body, env, commit } = reviewed();
|
||||
const head = commit({ 'src/app.ts': 'export const a = 3;\n' }, 'fix: a=3');
|
||||
const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.out.trim().split('\n')[0]).toBe('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
const m = eligible(refused.err);
|
||||
expect(m).not.toBeNull();
|
||||
expect(fs.readFileSync(m![3]!, 'utf8')).toContain('export const a = 3;');
|
||||
const record = path.join(repo.root, 'recheck.json');
|
||||
fs.writeFileSync(record, JSON.stringify({ from, head, range_diff_sha256: m![2], reader: 'anthropic/claude-opus-5-5', verdict: 'clean' }));
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', record, '--out', path.join(repo.root, 'run')], env);
|
||||
expect([carried.status, carried.err.slice(0, 400)]).toEqual([EXIT.ok, expect.any(String)]);
|
||||
expect(carried.out.trim().split('\n')[0]).toBe(`CARRY: carried from ${from.slice(0, 12)} to ${head.slice(0, 12)} (scoped re-check by anthropic/claude-opus-5-5 (range-diff ${m![1]} lines))`);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt).toMatchObject({ head, carry: { from } });
|
||||
const stale = path.join(repo.root, 'stale.json');
|
||||
fs.writeFileSync(stale, JSON.stringify({ from: head, head, range_diff_sha256: 'f'.repeat(64), reader: 'x', verdict: 'clean' }));
|
||||
commit({ 'src/app.ts': 'export const a = 4;\n' }, 'fix: a=4');
|
||||
const bad = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', stale], env);
|
||||
expect([bad.status, bad.out.trim().split('\n')[0]]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']);
|
||||
}, 60_000);
|
||||
|
||||
test('a clean re-check cannot carry across a changed risk class', () => {
|
||||
const { repo, from, body, env, commit } = reviewed();
|
||||
const before = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.review_depth!;
|
||||
const head = commit({ 'src/app.ts': 'export const a = 3;\n', 'src/billing.ts': 'export function charge(cents: number) { return stripe.charges.create({ amount: cents }); }\n' }, 'feat: billing');
|
||||
const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
|
||||
const m = eligible(refused.err);
|
||||
expect(m).not.toBeNull();
|
||||
const record = path.join(repo.root, 'recheck.json');
|
||||
fs.writeFileSync(record, JSON.stringify({ from, head, range_diff_sha256: m![2], reader: 'x', verdict: 'clean' }));
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', record], env);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/risk class|review depth/);
|
||||
expect(before.risk ?? []).not.toContain('money');
|
||||
}, 60_000);
|
||||
|
||||
test('a legacy receipt, more changed paths than the walk names, and moved base context print RECHECK: not eligible', () => {
|
||||
const legacy = reviewed();
|
||||
fs.writeFileSync(legacy.body, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 5, head: legacy.from, base: 'main' } as any));
|
||||
legacy.commit({ 'src/app.ts': 'export const a = 3;\n' }, 'fix');
|
||||
expect(runBin(legacy.repo, 'gstack-ship-receipt', ['carry', '--body', legacy.body], legacy.env).err).toContain('RECHECK: not eligible (the receipt records no review_depth');
|
||||
|
||||
const many = Object.fromEntries(Array.from({ length: 22 }, (_, i) => [`src/f${i}.ts`, `export const f${i} = 1;\n`]));
|
||||
const wide = reviewed(many);
|
||||
wide.commit(Object.fromEntries(Object.keys(many).map(f => [f, 'export const changed = 2;\n'])), 'fix: all');
|
||||
expect(runBin(wide.repo, 'gstack-ship-receipt', ['carry', '--body', wide.body], wide.env).err).toContain('RECHECK: not eligible (more changed product paths');
|
||||
|
||||
const bin = reviewed({ 'src/app.wasm': '\0asm\x01\0\0\0' });
|
||||
bin.commit({ 'src/app.wasm': '\0asm\x01\0\0\0' + 'x'.repeat(4096) + '\0' }, 'fix: new wasm');
|
||||
expect(runBin(bin.repo, 'gstack-ship-receipt', ['carry', '--body', bin.body], bin.env).err).toContain('RECHECK: not eligible (a changed product path is binary');
|
||||
}, 90_000);
|
||||
|
||||
test('base context moved next to a reviewed hunk plus a product edit elsewhere is not eligible (red-team p1 repro)', () => {
|
||||
const lines = (n: number, edits: Record<number, string> = {}) => Array.from({ length: 10 }, (_, i) => edits[i + 1] ?? `line ${i + 1}`).join('\n') + '\n';
|
||||
const repo = makeFixtureRepo({ prefix: 'ship-recheck-ctx-' }); repos.push(repo);
|
||||
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10));
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10, { 6: 'branch line 6' }));
|
||||
repo.git('commit', '-qam', 'feat: c6');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, '## Summary\n');
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--body', body], env).status).toBe(EXIT.ok);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10, { 4: 'main line 4' }));
|
||||
repo.git('commit', '-qam', 'main: c4'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feat');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
|
||||
repo.git('commit', '-qam', 'fix: app');
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('RECHECK: not eligible (base context also moved');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 401,
|
||||
"why": "GSTA-258 L4: review fixes carry ledger_id and a Review-Finding trailer, late defers log at Step 11 (GST-1168), the result line names head= (GST-1179); then -11 bytes (#3926): trim the ship skeleton's GSTACK_RESULT paragraph by 11 bytes after the rule 19 merge of main 1ccab0d8 summed #3929's push-guards line and J1's head=<sha>; skeleton back to 61,418 under the fixed 61,420 cap"
|
||||
}
|
||||
Reference in new issue
Block a user