Files
gstack/test/review-fix-range.test.ts
Garry Tanandcapy-ai[bot] 576839cb8c v1.91.256.0 feat(ship): review loop converges (frozen scope, 3-pass cap, NO FINDINGS) and receipts carry across conflict-only merges (#3481)
* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges

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

* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type

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

* fix: pre-landing review fixes (pass 1) and the review-loop budget wording

Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.

gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.

Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.

Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).

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

* fix: pin the renumbered review-army stages and point the wording test at product code

CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.

Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.

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

* fix: date a review-log row from its binding start when it has no timestamp

The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.

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

* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)

Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:

- carry: a path the policy names under stamp_files, stamp_paths or
  release_outputs is metadata only while its change is a stamp (versionStampOnly
  for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
  applies; a logic change in a stamped source file is product. Every
  metadata-listed path is read at both heads and a non-stamp change drops its
  globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
  a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
  finding in the title or body refuses and a MEDIUM one needs a human, both
  recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
  diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
  timestamp, so a date-only value cannot drop a real pass.

Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.

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

* test(review-army): assert the Red Team branch the run took in the N+1 case

Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.

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

* v1.91.256.0 stamp

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>
2026-10-26 20:48:01 +00:00

293 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* #3339: /ship Step 9 and /review may re-review only the fix range, but a fix-range pass
* can never be the last review. The fix base comes from the review log (never
* the working tree or the caller) and is the commit whose tree the fixing pass
* started from, a narrowed row is never CURRENT and carries no
* start_wtree/end_wtree for Step 11.5 to bind, and narrowed dispatches stay out
* of specialist hit rates.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { canReuseSharedLibsAdvisory, reviewFreshness, sharedLibsFingerprint } from '../lib/review-evidence';
import { gitIn } from './helpers/scratch-repo';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
const W = 'a'.repeat(40);
let root: string, repo: string, env: NodeJS.ProcessEnv;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fix-range-'));
repo = path.join(root, 'repo');
fs.mkdirSync(repo);
env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state') };
delete env.GSTACK_HOME;
gitIn(repo, 'init -q -b feature');
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
gitIn(repo, 'add a.ts');
gitIn(repo, 'commit -q -m init');
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
function bin(name: string, args: string[]) {
const r = spawnSync(path.join(BIN, name), args, { cwd: repo, encoding: 'utf8', timeout: 60_000, env });
return { code: r.status, out: r.stdout.trim(), err: r.stderr };
}
const head = () => gitIn(repo, 'rev-parse HEAD').trim();
const edit = (text: string) => fs.writeFileSync(path.join(repo, 'a.ts'), text);
const commit = () => gitIn(repo, 'commit -q -am fix');
const row = (fields: Record<string, unknown>) => JSON.stringify({ skill: 'review', via: 'ship', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...fields });
const fixing = { status: 'issues_found', issues_found: 1, completed: true, converged: false };
const clean = { completed: true, converged: true };
// Step 9.4's order: start, review, apply fixes, log with the original token, then commit the fixes.
function pass(fields: Record<string, unknown>, start: string[] = ['--start', 'review'], fixes?: string, commitFixes = true) {
const started = bin('gstack-review-log', start);
expect(started.code).toBe(0);
const [token, base] = started.out.split('\n');
if (fixes) edit(fixes);
expect(bin('gstack-review-log', [row(fields), '--finish', token!]).code).toBe(0);
if (fixes && commitFixes) commit();
return base;
}
function rows(): any[] {
return bin('gstack-review-read', []).out.split('\n---CONFIG---')[0]!.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
}
const narrowStart = () => bin('gstack-review-log', ['--start', 'review', '--fix-range']);
describe('fix-range start', () => {
test('after a completed fixing pass it prints the token and the reviewed commit as FIX_BASE', () => {
const reviewed = head();
pass(fixing, undefined, 'export const a = 2;\n');
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`);
expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts');
});
test('fixes still uncommitted at the repeat are inside the range', () => {
const reviewed = head();
pass(fixing, undefined, 'export const a = 2;\n', false);
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`);
expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts');
});
test('chained fix-range passes take the latest fixing pass as base', () => {
pass(fixing, undefined, 'export const a = 2;\n');
const second = head();
pass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n');
expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${second}`);
});
test.each([
['no earlier pass', () => {}, 'no earlier pass on this branch'],
['a clean latest pass', () => pass(clean), 'the latest pass made no fixes'],
['an incomplete latest pass (coverage restoration stays full)', () => pass({ ...fixing, status: 'unavailable', completed: false }, undefined, 'export const a = 2;\n'), 'the latest pass did not complete'],
['a Step 11 review after the latest pass', () => {
pass(fixing, undefined, 'export const a = 2;\n');
pass({ skill: 'adversarial-review', ...clean }, ['--start', 'adversarial-review']);
}, 'a Step 11 review followed the latest pass'],
['a latest pass logged without a start capture', () => {
edit('export const a = 2;\n');
expect(bin('gstack-review-log', [row(fixing)]).code).toBe(0);
}, 'the latest pass has no captured start'],
['a pass that started from uncommitted content (a reverted dirty hunk would fall outside the range)', () => {
edit('export const a = 1; // dirty\n');
pass(fixing, undefined, 'export const a = 1;\n', false);
}, 'started from uncommitted content'],
['a pass that started with an untracked file', () => {
fs.writeFileSync(path.join(repo, 'b.ts'), 'export const b = 1;\n');
pass(fixing, undefined, 'export const a = 2;\n');
}, 'started from uncommitted content'],
['a base that is no longer an ancestor of HEAD', () => {
edit('export const a = 2;\n');
commit();
pass(fixing, undefined, 'export const a = 3;\n');
gitIn(repo, 'reset -q --hard HEAD~2');
edit('export const a = 4;\n');
commit();
}, 'is not an ancestor of HEAD'],
])('refuses %s', (_, setup, reason) => {
setup();
const r = narrowStart();
expect(r.code).toBe(1);
expect(r.out).toBe('');
expect(r.err).toContain('fix-range refused: ');
expect(r.err).toContain(reason);
expect(r.err).toContain('run a full pass (gstack-review-log --start review)');
});
test('rejects an unknown start flag with usage', () => {
expect(bin('gstack-review-log', ['--start', 'review', '--narrow']).code).toBe(2);
});
});
describe('a fix-range pass is never the last review', () => {
test('its row is fix-range scoped whatever the caller claims, unbindable at Step 11.5 and never CURRENT', () => {
const reviewed = head();
pass(fixing, undefined, 'export const a = 2;\n');
pass({ ...clean, scope: 'full', fix_base: 'f'.repeat(40), review_binding: { state: 'verified', start_wtree: W, end_wtree: W }, wtree: W },
['--start', 'review', '--fix-range']);
const last = rows().at(-1);
expect(last.scope).toBe('fix-range');
expect(last.fix_base).toBe(reviewed);
expect(last.review_binding.state).toBe('fix-range');
expect(last.review_binding.range_start_wtree).toBe(last.review_binding.range_end_wtree);
expect(last.review_binding).not.toHaveProperty('start_wtree');
expect(last.review_binding).not.toHaveProperty('end_wtree');
expect(last).not.toHaveProperty('wtree');
expect(last.review_freshness).toEqual({ status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' });
expect(narrowStart().err).toContain('the latest pass made no fixes');
pass(clean);
expect(rows().at(-1).review_freshness.status).toBe('CURRENT');
});
test('a fixing fix-range pass is not CURRENT either', () => {
pass(fixing, undefined, 'export const a = 2;\n');
pass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n');
const last = rows().at(-1);
expect(last.scope).toBe('fix-range');
expect(last.review_freshness.status).not.toBe('CURRENT');
});
test('a caller cannot narrow a full pass by claiming fix-range', () => {
pass({ ...clean, scope: 'fix-range', fix_base: W });
const last = rows().at(-1);
expect(last).not.toHaveProperty('scope');
expect(last).not.toHaveProperty('fix_base');
expect(last.review_freshness.status).toBe('CURRENT');
});
test('a start token that still names a fix base stays narrowed even if its scope is rewritten', () => {
pass(fixing, undefined, 'export const a = 2;\n');
const [token] = narrowStart().out.split('\n');
const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!;
const file = path.join(root, 'state', 'projects', slug, '.review-starts', `${token}.json`);
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), scope: 'full', fix_base: 'not-a-commit' }));
expect(bin('gstack-review-log', [row(clean), '--finish', token!]).code).toBe(0);
const last = rows().at(-1);
expect(last.scope).toBe('fix-range');
expect(last).not.toHaveProperty('fix_base');
expect(last.review_freshness.status).toBe('UNVERIFIED');
});
test('freshness rejects a fix-range row even with a verified-looking binding', () => {
const rec = { skill: 'review', scope: 'fix-range', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: W,
review_binding: { state: 'verified', start_wtree: W, end_wtree: W } };
expect(reviewFreshness(rec, W)?.status).toBe('UNVERIFIED');
expect(reviewFreshness({ ...rec, scope: undefined }, W)?.status).toBe('CURRENT');
});
test('shared-libs advisory reuse never trusts a fix-range row', () => {
const finding = { severity: 'INFORMATIONAL', advisory: true, action: 'skipped', evidence_paths: ['a.ts'], helper_target: { path: 'b.ts', symbol: 'b' } };
const prior = { ...finding, fingerprint: sharedLibsFingerprint(finding), snapshot_covered_paths: ['a.ts'] };
const snapshot = { wtree: W, branch_id: 'd'.repeat(64), covered_paths: ['a.ts'] };
const full = { skill: 'review', completed: true, converged: true, wtree: W, review_binding: { state: 'verified', start_wtree: W, end_wtree: W, branch_id: 'd'.repeat(64) } };
expect(canReuseSharedLibsAdvisory(prior, finding, full, snapshot)).toBe(true);
const narrowed = { ...full, scope: 'fix-range', review_binding: { state: 'fix-range', range_start_wtree: W, range_end_wtree: W, branch_id: 'd'.repeat(64) } };
expect(canReuseSharedLibsAdvisory(prior, finding, narrowed, snapshot)).toBe(false);
});
});
describe('gstack-specialist-stats', () => {
test('fix-range passes do not count toward hit rates or gate candidacy', () => {
const entry = (scope?: string) => JSON.stringify({ ...(scope ? { scope } : {}), specialists: { testing: { dispatched: true, findings: 0 } } });
const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!;
const dir = path.join(root, 'state', 'projects', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'feature-reviews.jsonl'), [...Array(9).fill(entry()), ...Array(5).fill(entry('fix-range'))].join('\n') + '\n');
const out = bin('gstack-specialist-stats', []).out;
expect(out).toContain('SPECIALIST_STATS: 9 reviews analyzed');
expect(out).toContain('testing: 9/9 dispatched, 0 findings (0%)');
expect(out).not.toContain('GATE_CANDIDATE');
});
});
describe('ship Step 9 instructions', () => {
// Harness lane E (issue row 1): the fix-range token's mandatory final full pass re-read the
// whole tree and found new neighbours every time. /ship now reads only the range since the
// previous pass (RANGE_BASE) under a full token, so the final record still binds the tree.
const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' ');
test('pass 2+ reads the range since the previous pass under a full token; the fix-range token is /review\'s alone', () => {
expect(army).toContain('Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on, `RANGE_BASE` is the previous pass\'s PASS_HEAD.');
expect(army).toContain('use `DIFF_BASE=$RANGE_BASE` instead, so every reviewer reads what changed since the previous pass.');
expect(army).toContain('Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range.');
expect(army).not.toContain('--start review --fix-range');
expect(army).toContain('gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE');
expect(army).toContain('A pass whose only findings were deferred or noted applied no edits and continues here: the loop converges below the floor.');
});
});
describe('review Step 3 and Step 5.8 instructions', () => {
const skill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf8');
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
const step58 = skill.slice(skill.indexOf('## Step 5.8: Persist Eng Review result'), skill.indexOf('### Report the final review'));
const flat3 = step3.replace(/\s+/g, ' '), flat58 = step58.replace(/\s+/g, ' ');
test('the first pass starts full and only the pass after an edited pass narrows its DIFF_BASE, never Step 4.8 or other base uses', () => {
const block = step3.slice(step3.indexOf('```bash'), step3.indexOf('```', step3.indexOf('```bash') + 7));
expect(block).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
expect(block).not.toContain('--fix-range');
expect(flat3).toContain('After an edited pass, start the next with `--start review --fix-range`: it prints REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full. On refusal, start a full pass.');
expect(skill.match(/--fix-range/g)).toHaveLength(1);
});
test('an edited pass is logged before the repeat, kept out of Step 5.0, and a fix-range pass is never the final review', () => {
const edited = flat58.indexOf("Edited: increment CYCLES once, then fill and save the record below with `converged:false`. Step 5.0 never treats this invocation's records as prior reviews.");
const repeat = flat58.indexOf('Below 3, repeat Steps 3–5 with a new REVIEW_START.');
const none = flat58.indexOf('No edits: after a full pass, fill the record below. A fix-range pass is never final: repeat Steps 3–5 with a full start.');
expect(edited).toBeGreaterThan(-1);
expect(repeat).toBeGreaterThan(edited);
expect(none).toBeGreaterThan(repeat);
expect(flat58).toContain("finish only this pass's own token");
});
});
describe('review Step 3/5.8 order against the real logger', () => {
// /review's order on every pass: core start, Step 4.8 attempt start and finish, fixes in the working tree, core finish; never a commit.
const reviewRow = (fields: Record<string, unknown>) => JSON.stringify({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...fields });
function reviewPass(fields: Record<string, unknown>, start: string[], fixes?: string) {
const started = bin('gstack-review-log', start);
expect(started.code).toBe(0);
const [token, base] = started.out.split('\n');
// Step 4.8: each pass's adversarial attempt saves its row before any fix and before the core row.
const attempt = bin('gstack-review-log', ['--start', 'adversarial-review']);
expect(attempt.code).toBe(0);
expect(bin('gstack-review-log', [JSON.stringify({ skill: 'adversarial-review', status: 'clean', source: 'claude', ...clean }), '--finish', attempt.out]).code).toBe(0);
if (fixes) edit(fixes);
expect(bin('gstack-review-log', [reviewRow(fields), '--finish', token!]).code).toBe(0);
return base;
}
test('a committed first pass narrows the next repeat to the uncommitted fixes, then the chain returns to a full pass', () => {
const reviewed = head();
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
expect(reviewPass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n')).toBe(`FIX_BASE=${reviewed}`);
expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts');
const third = narrowStart();
expect(third.code).toBe(1);
expect(third.err).toContain('started from uncommitted content');
reviewPass(clean, ['--start', 'review']);
const last = rows().at(-1)!;
expect(last.scope).toBeUndefined();
expect(reviewFreshness(last, last.wtree)?.status).toBe('CURRENT');
}, 30_000);
test('a zero-edit fix-range pass is never CURRENT even if it were logged, so the final record needs a full pass', () => {
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
reviewPass(clean, ['--start', 'review', '--fix-range']);
const narrowed = rows().at(-1)!;
expect(narrowed.scope).toBe('fix-range');
expect(reviewFreshness(narrowed, narrowed.review_binding.range_end_wtree)?.status).toBe('UNVERIFIED');
}, 30_000);
test('a first pass over uncommitted work cannot be narrowed', () => {
edit('export const a = 1; // uncommitted\n');
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
const r = narrowStart();
expect(r.code).toBe(1);
expect(r.err).toContain('started from uncommitted content');
}, 30_000);
});