diff --git a/changelog.d/gst-1130-fix-check-range.md b/changelog.d/gst-1130-fix-check-range.md new file mode 100644 index 000000000..0bae56e39 --- /dev/null +++ b/changelog.d/gst-1130-fix-check-range.md @@ -0,0 +1,9 @@ +**A granted fix-check's clean read now counts at Step 16.** On gstack-evals#168 the owner granted one scoped fix-check, the reader read `9b0c56c..7ea1907` and found nothing, and `gstack-ship-release finish` still stopped with `STOP: behavior, tests or build inputs changed since review (tests/panel-kit-v3-1.test.ts)`, sending the test file the fix-check had read back through Steps 5 to 11.5. The review-loop exception could not help, because it is the owner accepting unreviewed edits and it never accepts a gate-edited test. The fix-check row also read `UNVERIFIED`, because it was logged without its `completed` and `converged` fields (GST-1130, GEVA-218). + +### Fixed +- **`gstack-ship-release finish`** reads the newest `review-fix-check` row. When the row is a completed clean read on committed content with its `range_base` on the branch, every path inside that range that matched the reviewed tree at the range base and is unchanged since routes as `fix_checked=` on the `ROUTE:` line, with a `FIX_CHECK: reviewed ` line. That includes a gate-edited test. A path edited after the fix-check still stops. A row that does not qualify prints `FIX_CHECK: not counted ()` and changes nothing. +- **`gstack-review-log`** refuses a `review-fix-check` row that does not carry `completed` and `converged` as true or false, and keeps the start token for the retry. The refusal names the missing fields, so the row is fixed when it is written instead of reading `UNVERIFIED` at Step 11.5. + +### Tests +- `test/ship-rereview-triggers.test.ts`: a fixture shaped like gstack-evals#168 (a round-2 pass, then a fix commit that edits the source and a gate-edited test) stops on main and passes finish once a clean fix-check row with its range base is logged through the real `gstack-review-log`. Negative controls: an edit after the fix-check still stops, and an incomplete row or a row with no range base is not counted. +- `test/review-log-findings.test.ts`: a `review-fix-check` row missing either field, or with a non-boolean value, is refused and keeps its start token; the complete row binds as verified. diff --git a/docs/review-policy.md b/docs/review-policy.md index b886d927b..24057a892 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -493,6 +493,19 @@ through the docs stage. A path outside that scope and a gate-edited test still route as behavior, with `--reviewed-base` (as /ship passes it) so does a file too large for the scoped review, and without the record nothing changes. +A completed fix-check is a review, not an owner acceptance (GST-1130). Finish +reads the newest `review-fix-check` row from `gstack-review-read`. When it is a +completed clean read on committed content (freshness `CURRENT` against its own +tree) whose `range_base` and commit are on the branch, each path inside +`range_base..commit` that matched the reviewed tree at `range_base` and is +unchanged since routes as `fix_checked=` in `ROUTE:` and is listed on a +`FIX_CHECK: reviewed` line, a gate-edited test included. A path edited after the +fix-check still routes by the rules above. Any other row prints +`FIX_CHECK: not counted ()` and changes nothing. `gstack-review-log` +refuses a `review-fix-check` row without boolean `completed` and `converged`, +before it consumes the start token, because without them the row reads +`UNVERIFIED` and finish cannot count it. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts index 60dc4e669..f4cd47549 100644 --- a/lib/review-evidence.ts +++ b/lib/review-evidence.ts @@ -272,6 +272,9 @@ export function bindReview(rec: Record, token: string, env = proces if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE; if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true'; stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND); + if (rec.skill === 'review-fix-check' && (typeof rec.completed !== 'boolean' || typeof rec.converged !== 'boolean')) { + throw new Error('review-log: a review-fix-check row must carry completed and converged as true or false (review-army item 6 fields; without them gstack-review-read marks the row UNVERIFIED and finish cannot count it)'); + } // A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army // item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the // logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue. diff --git a/lib/ship-release.ts b/lib/ship-release.ts index 02f5218a7..088d40e6f 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -15,6 +15,7 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check'; import { versionAtRef } from './restamp'; import { classifyPath } from './review-depth'; import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; +import { reviewFreshness } from './review-evidence'; import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth'; import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; import { resolveStateRoot } from './state-root'; @@ -160,8 +161,12 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin return true; } -/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */ -type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>; +/** + * Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). + * A path the newest completed clean review-fix-check row read (inside its range_base..commit range, the same at range_base as + * in the reviewed tree, and unchanged since) counts as fix_checked: a granted fix-check after Step 11 is a review (GST-1130). + */ +type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted' | 'fix_checked', string[]>; function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null { const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim(); if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; } @@ -181,7 +186,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now }; touched = new Set([...a, ...b]); } - const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] }; + const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [], fix_checked: [] }; for (const p of [...changedSet].sort()) { const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed'; by[verdict === 'same' ? 'base' : release.includes(p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p); @@ -196,6 +201,24 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string by.context = by.context.filter(p => !packetBody.over.includes(p)); by.tests = by.tests.filter(p => !packetBody.over.includes(p)); } + const fixRow = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n') + .flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } }).at(-1); + const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : ''; + const onBranch = (c: string) => !!c && git(cwd, ['merge-base', '--is-ancestor', c, 'HEAD']).status === 0; + const fixCommit = commitOf(fixRow?.commit_full), fixFrom = commitOf(fixRow?.range_base); + const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree); + const notCounted = !fixRow ? '' : fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row' + : fixRow.tree !== fixRow.wtree || !onBranch(fixCommit) ? 'the row was logged on content no branch commit holds' + : !onBranch(fixFrom) ? 'the row names no range_base commit on this branch' : ''; + if (notCounted) r.add(`FIX_CHECK: not counted (${notCounted})`); + const counted = !!fixRow && !notCounted; + if (counted) { + const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now), before = touchedPaths(cwd, reviewed, fixFrom); + if (!range || !after || !before) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; } + by.fix_checked = [...by.behavior, ...by.tests, ...by.context, ...by.docs].filter(p => range.has(p) && !after.has(p) && !before.has(p)).sort(); + for (const k of ['behavior', 'tests', 'context', 'docs'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p)); + r.add(`FIX_CHECK: reviewed ${by.fix_checked.length ? short(by.fix_checked) : 'none'} (fix-check at ${fixCommit.slice(0, 12)}, range from ${fixFrom.slice(0, 12)})`); + } const loop = opts.loop; if (loop) { const head = loop.fix_check.head; @@ -210,7 +233,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string by.behavior = by.behavior.filter(p => !by.accepted.includes(p)); by.docs = by.docs.filter(p => !by.accepted.includes(p)); } - r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`); + r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${counted ? ` fix_checked=${by.fix_checked.length}` : ''}${loop ? ` accepted=${by.accepted.length}` : ''}`); if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`); if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; } const scoped = [...by.context, ...by.tests].sort(); diff --git a/test/review-log-findings.test.ts b/test/review-log-findings.test.ts index c463521c3..2732b09ab 100644 --- a/test/review-log-findings.test.ts +++ b/test/review-log-findings.test.ts @@ -191,6 +191,23 @@ describe('lib: deriveFromFindings, bindReview order and reviewFreshness', () => expect(reviewFreshness(bound, 'd'.repeat(64))?.status).toBe('STALE'); }); + // Value: protects=a fix-check row is written with the fields review-read verifies (GEVA-218: the row lacked them, so it read + // UNVERIFIED and finish could not count it); fails_when=the logger accepts a review-fix-check row without completed and + // converged, or consumes the start token while refusing it; seam=bindReview + test('a review-fix-check row without completed and converged is refused and keeps its start token (GST-1130)', () => { + const dir = path.join(home, 'starts', '.review-starts'); + fs.mkdirSync(dir, { recursive: true }); + const token = '77777777-2222-3333-4444-555555555555'; + const w = 'e'.repeat(64); + fs.writeFileSync(path.join(dir, `${token}.json`), JSON.stringify({ skill: 'review-fix-check', repo: '/r', branch: 'b', wtree: w, started_at: 't' })); + const env = { GSTACK_REVIEW_DIR: path.join(home, 'starts'), GSTACK_REVIEW_REPO: '/r', GSTACK_REVIEW_BRANCH: 'b', GSTACK_STAMP_WTREE: w }; + for (const row of [{}, { completed: true }, { converged: true }, { completed: 'yes', converged: true }]) { + expect(() => bindReview({ skill: 'review-fix-check', status: 'clean', ...row }, token, env)).toThrow('review-log: a review-fix-check row must carry completed and converged as true or false'); + } + expect(fs.existsSync(path.join(dir, `${token}.json`))).toBe(true); + expect(bindReview({ skill: 'review-fix-check', status: 'clean', completed: true, converged: true }, token, env).review_binding.state).toBe('verified'); + }); + test('reviewFreshness reads findings_open: resolved criticals are CURRENT, claimed issues_found stays conservative', () => { const w = 'b'.repeat(64); const base = { skill: 'review', completed: true, converged: true, wtree: w, review_binding: { state: 'verified', start_wtree: w, end_wtree: w } }; diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 4e465c0a4..54abd32d7 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -739,3 +739,51 @@ describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-27 }); }); + +// Value: protects=a granted fix-check's clean read clears the paths it reviewed at Step 16, a gate-edited test included, so +// finish does not send them back through Steps 5–11.5 (GEVA-218, gstack-evals#168); fails_when=finish routes a path inside +// the fix-check's range as behavior, counts a path edited after the fix-check, or counts a row that is not a completed clean +// fix-check with a range base; seam=finish's route reading the review log; no_claim=Step 11.5's binding +describe('finish after a completed fix-check (GST-1130, GEVA-218)', () => { + const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n"; + const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 1_000);\ntest('b finishes fast', () => { expect(1 + 1).toBe(2); }, 1_000);\n"; + const logFixCheck = (f: ShipFx, row: Record) => { + const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', 'review-fix-check']); + expect(start.status).toBe(0); + return f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-02T00:00:00Z', via: 'ship', review_scope: 'frozen', status: 'clean', issues_found: 0, critical: 0, ...row }), '--finish', start.stdout.trim()]); + }; + const afterFixCheck = () => { + const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 }); + const passHead = f.git('rev-parse', 'HEAD').stdout.trim(); + const reviewed = wtree(f); + f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker and bound its case'); + return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), reviewed, post: snapshot(f) }; + }; + + test('paths inside a completed clean fix-check range count as reviewed, a gate-edited test included; a later edit still stops', () => { + const { f, passHead, fixHead, reviewed, post } = afterFixCheck(); + const plain = finish(f, reviewed, post); + expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]); + expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0); + const r = finish(f, reviewed, post); + expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)})`); + expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 fix_checked=2$/); + expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]); + f.write({ 'src/x.ts': 'export const x = 4;\n' }); f.commit('an edit after the fix-check'); + const late = finish(f, reviewed, post); + expect(lineOf(late.stdout, 'FIX_CHECK')).toStartWith('FIX_CHECK: reviewed test/x.test.ts ('); + expect([late.status, late.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]); + }); + + test('a fix-check row that did not finish clean, or names no range base, is not counted', () => { + const { f, passHead, reviewed, post } = afterFixCheck(); + expect(logFixCheck(f, { completed: false, converged: false, range_base: passHead }).status).toBe(0); + const incomplete = finish(f, reviewed, post); + expect(lineOf(incomplete.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (missing start capture or incomplete/nonconverged pass)'); + expect([incomplete.status, incomplete.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]); + expect(logFixCheck(f, { completed: true, converged: true }).status).toBe(0); + const unranged = finish(f, reviewed, post); + expect(lineOf(unranged.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row names no range_base commit on this branch)'); + expect(unranged.status).toBe(3); + }); +});