From a65404886b02987ee1b22df430f06efc41fab109 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 18:56:02 -0800 Subject: [PATCH] v1.93.38.0 fix(review): one record per native finding at its stated severity; blocker count keys on Blocks; fix-check is never a pass (GST-1031) (#3894) * fix(review): one record per native finding at its stated severity; blocker count keys on Blocks; fix-check start; harness re-pin is metadata (GST-1031) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): summary candidates need a denied or fixed label; only a Blocks: field lowers the count (GST-1031 review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): JSON findings state no Blocks field; the code-class FIXABLE check takes only a Blocks: field (GST-1031 review pass 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a Blocks yes in any form vetoes unblocking; summary candidates need a closing noun and cover label lists (GST-1031 Step 11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * revert(review): keep the pinned adversarial brief files at their no-loss bytes (GST-1031) 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> --- CHANGELOG.md | 4 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-dispatch | 4 +- docs/review-policy.md | 27 ++-- lib/capy-review-pass.js | 5 +- lib/review-ledger.ts | 69 ++++++--- lib/review-scope.ts | 14 +- lib/ship-release.ts | 16 +- package.json | 2 +- .../review-ledger/gst-1031-fix-check.txt | 6 + .../review-ledger/gst-1031-native.txt | 34 ++++ test/prose-voice-floor.test.ts | 8 +- test/review-dispatch.test.ts | 14 +- test/review-fixable-class.test.ts | 8 +- test/review-gst1031.test.ts | 145 ++++++++++++++++++ test/review-ledger-invariant.test.ts | 5 +- test/review-pass.test.ts | 15 ++ test/ship-release.test.ts | 23 +++ 19 files changed, 347 insertions(+), 56 deletions(-) create mode 100644 test/fixtures/review-ledger/gst-1031-fix-check.txt create mode 100644 test/fixtures/review-ledger/gst-1031-native.txt create mode 100644 test/review-gst1031.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dae015abc..0740836fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.93.38.0] - 2026-11-03 + +- **Review collector and blocker count follow what the reader said (GST-1031).** A native adversarial finding that states `Blocks: no` and one Severity now collects once, at that severity, on every review class; a numbered finding heading keeps its body (located or numbered bullets, a bold subheading, a located paragraph) so its Severity line no longer splits off as a second CRITICAL record. A closing summary such as "so nothing is CRITICAL" is marked `summary_candidate`, and lead judgment may lower it with a reason that `judge` records as `dismissed`. A CRITICAL whose reader writes the field `Blocks: no` no longer counts as a blocker in `REVIEW_LOOP`'s ledger count (classify does the same for a finding that carries `stated`; Step 9.4 does not copy it yet). Decision 3.5's fix-check pass takes a `review-fix-check` start token even when given `logStart: ["review"]`, so it is never counted as a pass. `gstack-ship-release finish` routes a `scripts/harness-version.json` edit that only records non-behavioral pins at the same version as release metadata. + ## [1.93.37.0] - 2026-11-03 **Eval planning prices Claude Haiku 5.5, GPT-6 Luna and Gemini 4 Argon, and the Codex docs name the current OpenAI tiers (#3640, #2943).** Before, `gstack-eval-plan arms --arms claude-haiku-5-5` stopped with EVAL_PRICE_MISSING because `lib/pricing.ts` had no row for it. docs/hosts.md and the /codex example still named gpt-5.6-sol as the current Sol model. diff --git a/VERSION b/VERSION index c6dda2541..499e69b38 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.37.0 +1.93.38.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 75530d6d5..ad4f63936 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.37.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.38.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index fd121faf1..b5b00be12 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -414,8 +414,8 @@ function main(): number { const judged = flag('--judged'); if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged and --judged \n'); return EXIT.usage; } const r = recordJudgment(ledger, readJsonl(merged), readJsonl(judged), judged, merged); - process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}\n` - : `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one)\n`); + process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}${r.dismissed.length ? ` dismissed=${r.dismissed.length}` : ''}\n` + : `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one; a summary_candidate line may be lowered with a reason)\n`); return r.ok ? EXIT.ok : EXIT.fail; } if (cmd === 'verify') { diff --git a/docs/review-policy.md b/docs/review-policy.md index b886d927b..89e4681d0 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -601,22 +601,30 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: second line for the same field starts another: `Severity` sets its label (a label leading the finding stays its floor), `Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline `Blocks: yes` is CRITICAL too, and with no finding above it is one. The native - adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167) unless collect `--manifest` names this - ledger's pass and its plan is the code or docs class with only `runtime=` depth reasons (no `risk:`, `error:`, - `history:` or `floor:` reason). On such a plan a native finding's own `Severity` field decides and FIXABLE beside it - raises nothing once it also states `Blocks: no`; a native FIXABLE finding without `Blocks: no`, with no `Severity` field, - or whose field names only FIXABLE or INVESTIGATE still collects as CRITICAL (GST-987). An unnumbered, unlabeled bullet with no file:line + adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167), except that on every class a native + finding that states `Blocks: no` and one `Severity` field collects at that field (GST-1031 ruling A); a native FIXABLE + finding without `Blocks: no`, with no `Severity` field, or whose field names only FIXABLE or INVESTIGATE still collects + as CRITICAL. On a code or docs plan with only `runtime=` depth reasons (collect `--manifest` naming this ledger's + pass; GST-987) FIXABLE also raises nothing through a label heading or a leading tag. A numbered finding heading keeps + every unlabeled line under it (located or numbered bullets, a bold subheading, a located paragraph) until its + `Severity` or `Blocks` line comes, so those fields never split off as a second record (GST-1031). An unnumbered, unlabeled bullet with no file:line under a paragraph finding whose Severity or Blocks line has not come yet is that finding's detail, so the field line after it lands on the finding; a numbered item is its own finding. A negated severity word still collects at its label ("found nothing CRITICAL", "No P0/P1 issues"), so a reader's - closing summary is a known phantom: it stays at that label in the judged set, lead judgment can fold it only into a - finding at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost + closing summary is a known phantom: it collects at that label. When it is a summary candidate (no lead label, no + `Severity` or `Blocks` field, and no file:line or exception clause in any sentence naming a label: "so nothing is + CRITICAL", "the test covers the CRITICAL fix"), collect marks it `summary_candidate` and lead judgment may lower it + with a reason, which judge records as `dismissed` (GST-1031); otherwise lead judgment can fold it only into a finding + at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost real blockers named in an exception clause ("No CRITICAL issues except the token leak in log.ts:3"); GSTA-167 reverted that. Each prose finding's line also records the Severity and Blocks its own text states as `stated`, beside the collected label and never in its place; a classified finding that carries `stated` shows it in its deferred-review issue next to the label ("CRITICAL (the reviewer wrote Severity: Low, Blocks: no)"). /ship's Step 9.4 does not copy `stated` into its findings file yet: that - instruction lives in a reviewer brief that changes only after a no-loss replay. What blocks is unchanged. + instruction lives in a reviewer brief that changes only after a no-loss replay. A CRITICAL whose own text writes the + field `Blocks: no` (colon required, and no `Blocks: yes`) is not counted as a blocker by the ledger count, nor by the + review-log count or classify when the finding carries `stated` (GST-1031); lead judgment that raises such a line above + its label counts it again. A phantom finding costs a fold; a lost one costs the gate. A reply with no finding counts as a review only with the clean marker its own brief asks for: `NO FINDINGS` alone on a line for the specialists, the batched specialist, the targeted reader, the Red Team and fix-check, the `Recommendation:` @@ -690,7 +698,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout. A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and dispatches. Step 11's voices take the `adversarial-review` start - token by default, and a `logStart` list without it stops before anything runs. + token by default, and a `logStart` list without it stops before anything runs. Decision 3.5's fix-check pass turns a + `review` start into `review-fix-check`, so its row is never a pass (GST-1031). ## Where this is enforced diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 886976f34..033cce32d 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -1,7 +1,7 @@ // gstack /ship review pass program for Capy (lib/capy-review-pass.js, GSTA-167 calls PR 2). // The body of an async function (tools, sleep, now, input), loaded verbatim by the loader in // ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass (decision 3.5's -// fix-check with its plan's voices included) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`, +// fix-check with its plan's voices included, whose `review` start becomes `review-fix-check`, never a pass: GST-1031) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`, // intent before create, create, poll, collect. It returns 200 s after the loader's first line // (input.startedAt) at most; the next call resumes from the manifest. A failed, stuck or ambiguous // reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped. @@ -145,8 +145,9 @@ try { if (start.exit_code !== 0) return stop('cannot read the directory readers on the shared machine start in: ' + got.slice(-300) + '; no start token was taken and no reader was dispatched'); if (got !== 'NOT_A_CHECKOUT' && got !== want) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + " (the thread's working directory) on this branch, with a fresh --out"); } + const starts = input.stage !== 'voices' && m.next === 'fix-check' ? logStart.map((s) => (s.split(' ')[0] === 'review' ? 'review-fix-check' + s.slice(6) : s)) : logStart; const seen = {}; - for (const s of logStart) { + for (const s of starts) { const nth = (seen[s] = (seen[s] || 0) + 1); const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : ''); if ((m.log_starts || {})[key]) continue; diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index dce6fde5d..7ace70d5b 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -59,7 +59,6 @@ const NUMBERED_HEAD = /^(?:#{1,6}\s+|\*\*\s*)\d+[.)]\s/; const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a : b); /** A prose voice's `Blocks: yes` (GSTA-167 normalizer v2): the finding is CRITICAL whatever its label; `Blocks: no` never lowers one. */ const BLOCKS_YES = /(?]\\s+|\\d+[.)]\\s+)?\\**\\s*Sever const FIELD_LABELS = new RegExp(ANY_LABEL.source, 'gi'); const LEAD_LABEL = new RegExp(`^(?:[^\\p{L}\\p{N}\\s]\\s*|\\d+[.)]\\s*)*(?:\\w[\\w./-]*:\\d+(?:[-–:,]\\s*\\d+)*[^\\p{L}\\p{N}\\s]*\\s+(?:[^\\p{L}\\p{N}\\s]\\s*)*)?(${LABELS})(?=[*_\`\\])]|\\s*[:|—–]|\\s+-\\s|\\s*$|(?<=[A-Z]{3})[\\s,(.])`, 'u'); const STATED_BLOCKS = /(? { if (raw.startsWith('{') || BLOCKS_YES.test(raw)) return false; const said = [...raw.matchAll(BLOCKS_FIELD)].map(m => m[1]!.toLowerCase()); return said.length > 0 && !said.includes('yes'); }; +/** + * A summary candidate (GST-1031): a prose finding whose every severity word is denied ("so nothing is CRITICAL", "No CRITICAL issues") + * or names a fix ("the test covers the CRITICAL fix"), with no lead label, no Severity or Blocks field, no FIXABLE or INVESTIGATE tag, + * and no file:line or exception clause in any sentence naming a label. A defect called CRITICAL ("this is CRITICAL") is none. It still collects at its label (a blocker named in another sentence stays on record, GST-435); lead + * judgment may lower it with a reason, which judge records as dismissed. "No CRITICAL issues except the token leak in log.ts:3" is no candidate. + */ +const SENTENCE = /(?<=[.!?])\s+/; +const EXCEPTION = /(? ANY_LABEL.test(t) && !ANY_LABEL.test(t.replace(DENIED_OR_FIXED, ' ')) && !t.startsWith('{') && !t.startsWith('**') && !LIST_ITEM.test(t) && !HEADING.test(t) && !LEAD_LABEL.test(t) + && !t.match(SEVERITY_FIELD) && !t.match(STATED_BLOCKS) && [...t.matchAll(ALL_LABELS)].every(m => !!rank(m[1])) + && t.split(SENTENCE).filter(s => ANY_LABEL.test(s)).every(s => !PATH_LOCUS.test(s) && !EXCEPTION.test(s)); +export function stated(raw: string): Stated | undefined { if (raw.startsWith('{')) return undefined; const severity = [...raw.matchAll(STATED_SEVERITY)].at(-1)?.[1]; - const blocks = [...raw.matchAll(STATED_BLOCKS)].at(-1)?.[1]?.toLowerCase(); + const said = [...raw.matchAll(BLOCKS_FIELD)].map(m => m[1]!.toLowerCase()); + const blocks = said.includes('yes') || (said.length && BLOCKS_YES.test(raw)) ? 'yes' : said.at(-1); return severity || blocks ? { ...(severity ? { severity } : {}), ...(blocks ? { blocks } : {}) } : undefined; } -export interface MergedLine { id: string; reader: string; label: string; stated?: Stated; raw: string; output_sha256: string } +export interface MergedLine { id: string; reader: string; label: string; stated?: Stated; summary_candidate?: true; raw: string; output_sha256: string } /** * One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or @@ -104,8 +121,11 @@ export interface MergedLine { id: string; reader: string; label: string; stated? * starts the next finding, except an unlabeled, unnumbered bullet with no file:line under a paragraph finding whose fields have not * come yet, which is its detail and joins its text (GSTA-196: 4 findings with detail bullets collected as 8). An inline * `Blocks: yes` is CRITICAL too. The native voice's FIXABLE collects as CRITICAL; other labels stay as written for lead judgment. When in doubt the parser merges rather than drops: a lost blocker costs the gate. - * With fixableRaises false (a code or docs review with no risk, error, history or floor reason, GST-987) a native FIXABLE finding that - * states `Blocks: no` collects at its own Severity field; one with no Blocks: no, no Severity field or only a FIXABLE field stays CRITICAL. + * A numbered finding heading keeps every unlabeled line under it, located or listed or bold, until its fields come (GST-1031), so its + * Severity and Blocks never split off as a second record; a prose line that only names a severity in passing is a summary (summaryLine). + * A native FIXABLE finding that states `Blocks: no` and one Severity field collects at that field on every class (GST-1031 ruling A); + * one with no Blocks: no, no Severity field or only a FIXABLE field stays CRITICAL. fixableRaises false (a code or docs review with no + * risk, error, history or floor reason, GST-987) also drops FIXABLE's raise from label headings and leads. */ export function parseFindings(reader: string, output: string, fixableRaises = true): Array<{ label: string; raw: string }> { type Finding = { label: string; raw: string; fields: Set; list: boolean; lead?: string; fixable?: boolean; heading?: boolean }; @@ -155,6 +175,7 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr } const heading = LABEL_HEADING.exec(t); if (heading) { section = fixableRaises ? named(heading[1]!) : heading[1]!; current = null; head = ''; continue; } + if (current?.heading && !current.fields.size && !label && !blocks && !/^#{1,6}\s/.test(t) && !NUMBERED_HEAD.test(t) && !t.startsWith('{')) { current.raw += ` | ${t}`; continue; } if (!indented && HEADING.test(t)) { section = null; current = null; } if (t.startsWith('{')) { try { @@ -188,8 +209,13 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr } if (!fresh && !current && (!LIST_ITEM.test(head) || locus)) head = t; } - const unblocked = (f: Finding) => fixableRaises || role !== 'adversarial-native' || !(f.fixable || f.label.toUpperCase() === 'FIXABLE' || /(? ({ label: unblocked(f) ? f.label : 'CRITICAL', raw: f.raw })); + const unblocked = (f: Finding) => fixableRaises || role !== 'adversarial-native' || !(f.fixable || f.label.toUpperCase() === 'FIXABLE' || /(? { const ls = [...raw.matchAll(SEVERITY_FIELD)].flatMap(m => (m[1] ?? m[2]!).match(FIELD_LABELS)!); return ls.length === 1 && !!rank(ls[0]); }; + return out.map((f, i) => { + const own = relaxed?.length === out.length && relaxed[i]!.raw === f.raw && statesNoBlocks(f.raw) && statesSeverity(f.raw) ? relaxed[i]!.label : undefined; + return { label: own ?? (unblocked(f) ? f.label : 'CRITICAL'), raw: f.raw }; + }); } /** @@ -232,7 +258,7 @@ export function collect(ledger: string, reader: string, output: string, mergedFi const wrongTree = !unavailable && tree !== undefined && !tree.ok; const lines = unavailable || wrongTree ? [] : parseFindings(reader, output, raises).map((f, i) => { const s = stated(f.raw); - return { id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, ...(s ? { stated: s } : {}), raw: f.raw, output_sha256: sha }; + return { id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, ...(s ? { stated: s } : {}), ...(summaryCandidate(f.raw) ? { summary_candidate: true as const } : {}), raw: f.raw, output_sha256: sha }; }); const review = !unavailable && !wrongTree && (lines.length > 0 || hasCleanMarker(reader, output)); fs.appendFileSync(mergedFile, lines.map(l => JSON.stringify(l) + '\n').join(''), { mode: 0o600 }); @@ -249,13 +275,13 @@ export function readJsonl(file: string): T[] { } export interface JudgedLine { id: string; severity: string; reason?: string; duplicate_of?: string } -export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[] } +export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[]; dismissed: string[] } -/** The lead-judgment rule: every merged finding survives at its severity or above; additions and promotions carry a reason. */ +/** The lead-judgment rule: every merged finding survives at its severity or above, except a summary candidate lowered with a reason (dismissed); additions and promotions carry a reason. */ export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment { const byId = new Map(judged.map(j => [j.id, j])); const mergedIds = new Set(merged.map(m => m.id)); - const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [] }; + const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [], dismissed: [] }; for (const j of judged) if (rank(j.severity) === null) res.invalid.push(j.id); for (const m of merged) { const j = byId.get(m.id); @@ -266,7 +292,7 @@ export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment { const from = rank(m.label) ?? 0; const to = rank(target.severity); if (to === null) continue; - if (to < from) res.downgraded.push(m.id); + if (to < from) (!j.duplicate_of && j.reason?.trim() && summaryCandidate(m.raw) ? res.dismissed : res.downgraded).push(m.id); else if (j.duplicate_of) res.folded.push(m.id); else if (to > from) { res.promoted.push(m.id); if (!j.reason?.trim()) res.unexplained.push(m.id); } } @@ -286,8 +312,9 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud if (res.ok) { for (const id of res.added) appendEvent(ledger, { event: 'judgment', kind: 'added', id, to: byId.get(id)!.severity, reason: byId.get(id)!.reason }); for (const id of res.promoted) appendEvent(ledger, { event: 'judgment', kind: 'promoted', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason }); + for (const id of res.dismissed) appendEvent(ledger, { event: 'judgment', kind: 'dismissed', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason }); } - appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length }); + appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length, dismissed: res.dismissed.length }); return res; } @@ -307,7 +334,8 @@ const treeHolds = (render: LedgerEvent | undefined, reply: string | undefined) = const realPath = (f: string) => { try { return fs.realpathSync(f); } catch { return path.resolve(f); } }; /** - * The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged + * The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL and whose reader did not state + * `Blocks: no` (GST-1031: the count keys on Blocks; lead judgment raising such a line above its label counts it), raised to its judged * severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded * duplicate does not. Every reader counts the same way, whatever it is and whether or not Step 9.4 classified its findings. * Each carries its locus (a JSON finding's path:line, else the first path:line its text names, an extensionless bin/ path @@ -331,12 +359,16 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] { text.split('\n').filter(l => l.trim()).map(l => { try { return JSON.parse(l); } catch { return fail(`${what} has a line that is not JSON`); } }); const events = jsonLines(read(ledger, 'ledger'), 'the ledger') as LedgerEvent[]; const ranks = new Map(); + const unblocking = new Map(); + const raws = new Map(); const loci = new Map(); for (const o of events.filter(e => e.event === 'output' && Array.isArray(e.ids) && e.ids.length)) { const lines = jsonLines(read(o.merged, `merged file of ${String(o.reader)}`), String(o.merged)) as MergedLine[]; for (const id of o.ids as string[]) { const line = lines.find(l => l.id === id) ?? fail(`collected id ${id} is missing from ${String(o.merged)}`); ranks.set(id, rank(line.label) ?? 0); + if (statesNoBlocks(line.raw)) unblocking.set(id, rank(line.label) ?? 0); + raws.set(id, line.raw); loci.set(id, lineLocus(line.raw)); } } @@ -346,11 +378,12 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] { if (promptHash(text) !== verdict.judged_sha256) fail(`judged set ${String(verdict.judged)} changed since judge`); for (const j of jsonLines(text, String(verdict.judged)) as JudgedLine[]) { if (j.duplicate_of) { ranks.delete(j.id); continue; } - ranks.set(j.id, Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0)); + const dismissed = !!j.reason?.trim() && summaryCandidate(raws.get(j.id) ?? ''); + ranks.set(j.id, dismissed ? rank(j.severity) ?? 0 : Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0)); if (!loci.has(j.id)) loci.set(j.id, lineLocus(j.reason ?? '')); } } - return [...ranks].filter(([, r]) => r >= 4).map(([id]) => ({ id, locus: loci.get(id) ?? null })); + return [...ranks].filter(([id, r]) => r >= 4 && !(r <= (unblocking.get(id) ?? -1))).map(([id]) => ({ id, locus: loci.get(id) ?? null })); } /** @@ -480,7 +513,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri const forged = differs.filter(id => judgedIds.has(id)); if (forged.length) notes.push(`merged_line_differs=${JSON.stringify(forged)}`); const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK'; - rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` }); + rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${v.dismissed ? ` dismissed=${v.dismissed}` : ''}${notes.length ? ' ' + notes.join(' ') : ''}` }); } else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' }); const clean = episodes.length > 0 && rows.every(r => ['MATCH', 'OK', 'VOIDED', 'UNAVAILABLE'].includes(r.status)); return { rows, clean, briefs: episodes.length, voided, unavailable: rows.filter(r => r.kind === 'BRIEF' && r.status === 'UNAVAILABLE').length }; diff --git a/lib/review-scope.ts b/lib/review-scope.ts index 620c3c11a..f40d49884 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -29,7 +29,7 @@ export const HUNK_CONTEXT_LINES = 3; export interface ScopeFinding { fingerprint?: string; path?: string; line?: number; severity?: string; category?: string; summary?: string; problem?: string; fix?: string; specialist?: string; advisory?: boolean; test_stub?: string; - /** The severity and Blocks the reviewer's own text stated, from its merged.jsonl line (GST-766); shown, never used to classify. */ + /** The severity and Blocks the reviewer's own text stated, from its merged.jsonl line (GST-766); `Blocks: no` keeps a CRITICAL below the floor (GST-1031). */ stated?: { severity?: string; blocks?: string }; /** The changed line that causes a defect reported elsewhere (a broken caller, a consumer of a removed export); the locus follows it. */ cause?: { path?: string; line?: number }; @@ -180,11 +180,13 @@ export function kindOf(finding: ScopeFinding): Kind { /** * The blocking floor: a CRITICAL defect (a test that would fail, a production - * break, a security or data-loss defect). Advisories, test gaps, wording and - * INFORMATIONAL items are below it and never trigger another pass. + * break, a security or data-loss defect) its reviewer did not mark `Blocks: no` + * (GST-1031). Advisories, test gaps, wording and INFORMATIONAL items are below + * it and never trigger another pass. */ +export const statesNoBlock = (f: Record) => String(f.stated?.blocks ?? '').toLowerCase() === 'no'; export function isBlocking(finding: ScopeFinding, kind = kindOf(finding)): boolean { - return kind === 'defect' && String(finding.severity ?? '').toUpperCase() === 'CRITICAL'; + return kind === 'defect' && String(finding.severity ?? '').toUpperCase() === 'CRITICAL' && !statesNoBlock(finding); } export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks: HunkMap; rangeHunks: HunkMap | null; context?: number } @@ -355,8 +357,8 @@ function rowDispatches(row: Record): Array<{ role: string; bytes: n /** Row actions that record what happened to a finding. */ const DISPOSITIONS = ['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', 'kept', 'restored', 'open']; -/** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect (logged before Step 9.4's classify). */ -const rowBlocks = (f: Record) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4); +/** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect its reviewer did not mark Blocks: no (logged before Step 9.4's classify). */ +const rowBlocks = (f: Record) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4 && !statesNoBlock(f)); /** The reviewer behind a row's raised_by or a ledger id: every specialist is one; the lead's additions and unknowns name none. */ function raiserOf(raiser: unknown): string | null { const role = typeof raiser === 'string' ? raiser.split(/[/#]/)[0]!.trim() : ''; diff --git a/lib/ship-release.ts b/lib/ship-release.ts index 02f5218a7..48796e691 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -160,6 +160,20 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin return true; } +/** + * A harness pin edit that only records changed harness files as non-behavioral at the same version and patterns (GST-1031): release + * metadata, like a version stamp. A bump, a pattern change or an entry without its reason is behavior. + */ +const HARNESS_PIN = 'scripts/harness-version.json'; +function harnessRepin(cwd: string, from: string, to: string, p: string): boolean { + if (p !== HARNESS_PIN) return false; + const read = (tree: string): any => { const s = git(cwd, ['show', `${tree}:${p}`]); try { return s.status === 0 ? JSON.parse(s.stdout) : null; } catch { return null; } }; + const a = read(from), b = read(to); + if (!a?.files || !b?.files || typeof b.files !== 'object' || a.version !== b.version || JSON.stringify(a.patterns) !== JSON.stringify(b.patterns)) return false; + return Object.entries(b.files as Record).every(([f, e]) => JSON.stringify(a.files[f]) === JSON.stringify(e) + || (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10)); +} + /** 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[]>; function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null { @@ -184,7 +198,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] }; 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); + by[verdict === 'same' ? 'base' : release.includes(p) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p); } const gated = gateEditedPaths(cwd, reviewed, now, by.tests) ?? new Set(by.tests); by.behavior.push(...by.tests.filter(p => gated.has(p))); diff --git a/package.json b/package.json index 2fe60ead2..9864d54ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.37", + "version": "1.93.38", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/review-ledger/gst-1031-fix-check.txt b/test/fixtures/review-ledger/gst-1031-fix-check.txt new file mode 100644 index 000000000..359217b4d --- /dev/null +++ b/test/fixtures/review-ledger/gst-1031-fix-check.txt @@ -0,0 +1,6 @@ +REVIEWED_TREE: 4d6a982968de8e11d2665976fc91587730a4767d /home/user/.capy/work/gst986-wt +{"severity":"INFORMATIONAL","path":"docs/troubleshooting.md","line":3307,"summary":"The RECEIPT_INVALID entry still describes the old AND rule: it says write refuses when the read commit's product patch is not the receipt's AND a later change carries a targeted class. The fixed code at lib/ship-receipt-depth.ts:153 refuses on either one. So a user who hits the refusal after a docs-only or TODOS.md commit (the Step 14 and 14.5 commits) finds no matching cause in this entry. docs/review-policy.md and the changelog fragment were updated; this entry was not."} +{"severity":"INFORMATIONAL","path":"lib/ship-receipt-depth.ts","line":153,"summary":"The fix list calls this main's rule, but it isn't. On origin/main, parseReviewTargeted(value, productPatchId) takes no head and binds any record. So under this change, every money, gate, auth or secret ship whose Step 14 or 14.5 commits after the Step 11 read now stops at Step 19 with RECEIPT_INVALID. That is the same outcome pass 1's Red Team flagged. Step 19's prose (ship/sections/pr-body.md:247, 'fix that input file and rerun') doesn't say to rerun the targeted reader on HEAD; only the tool's own error message does. The guard fails closed, and the code deliberately defers the docs-only case to GST-1031, so this is not data loss. It is a known extra stop in unattended /ship runs."} +{"severity":"INFORMATIONAL","path":"lib/ship-receipt-depth.ts","line":153,"summary":"When the product patches are equal and only classesSince refuses (a test edit after the read that skips a case), the message prints the same patch id twice: 'whose product patch X is not this receipt's X'. This was already true before the fix and the fix kept it. The message misstates the cause; the decision itself is correct."} + +I found nothing at the blocking floor. The OR guard fails closed in the right places: it refuses when the product patch differs, when classesSince returns null (unreadable), and when a class shows up after the read. Its only production caller, bin/gstack-ship-receipt:227, passes the same arguments as before. The test change does replace the old docs-binds assertion with a refusal, but that matches the intended new rule; it doesn't weaken coverage. The new no-money-token regression test covers the CRITICAL fix. I reran the three focused files at this tree: 34 pass, 0 fail, 403 expect() calls. diff --git a/test/fixtures/review-ledger/gst-1031-native.txt b/test/fixtures/review-ledger/gst-1031-native.txt new file mode 100644 index 000000000..3e676510d --- /dev/null +++ b/test/fixtures/review-ledger/gst-1031-native.txt @@ -0,0 +1,34 @@ +I reviewed the range base..HEAD, one commit that changes four files. I read every non-test file in full. + +## Findings + +**1. The family check refuses two models in one call even when no task is judged (check.ts:165-169). FIXABLE.** +The family check runs whether or not the run uses a judge. Elsewhere the judge is used only when `judged` is true. + +Before this commit the call passed. Now every judge stops with E_POLICY: +- the default judge clashes with the second model; +- the other judges clash with the first model. + +So the two models can no longer run in one call, even though the judge would never be called. It fails closed with a clear message, and two calls work around it. +Fix: `else if (judged && sameFamily.length)`, plus a test with an unjudged set. +Severity: Medium. Blocks: no. + +**2. max_price now uses the long-prompt rates for every call, short prompts included (prices.ts:75, set at proxy.ts:513). FIXABLE, with part to INVESTIGATE.** +`routing` sets max_price from the price row alone, whatever the prompt length. + +Today the only endpoints are standard, fast and ultrafast. + +Two more problems: +- The commit assumes the router checks max_price against long-tier rates. +- The new doc comment gives the wrong reason. + +Fix: build max_price per request. +Severity: Low. Blocks: no. + +**3. The docs' third-family judge barely exists (docs/eval.md:125, policy.ts:125-129). INVESTIGATE.** +Only weak judges have price rows, so in practice the remedy is two calls. + +Also, call.ts still picks the default judge by provider, not by family. +Severity: Low. Blocks: no. + +Recommendation: Fix the unconditional family check at check.ts:169, because gating it on `judged` is a one-line change. diff --git a/test/prose-voice-floor.test.ts b/test/prose-voice-floor.test.ts index 0cbe7ad27..10796354e 100644 --- a/test/prose-voice-floor.test.ts +++ b/test/prose-voice-floor.test.ts @@ -50,12 +50,12 @@ describe('normalizer v2 as collected: Blocks: yes is CRITICAL, Blocks: no never // Value: protects=the rule round 5's G1 was scored under, on the path production runs (gstack-review-dispatch collect); // fails_when=Blocks: yes collects below CRITICAL or Blocks: no lowers a label; seam=parseFindings test.each([ - ['FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', 'CRITICAL'], + ['FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', 'Medium'], ['INVESTIGATE: odd lock in b.ts:9\nSeverity: Low\nBlocks: yes', 'CRITICAL'], ['INVESTIGATE: odd lock in b.ts:9\nBlocks: no', 'INVESTIGATE'], - ['FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no', 'CRITICAL'], - ['1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no', 'CRITICAL'], - ['- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no', 'CRITICAL'], + ['FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no', 'Medium'], + ['1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no', 'Low'], + ['- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no', 'Medium'], ] as const)('native %j -> %s', (output, want) => expect(parseFindings('adversarial-native', output).map(g => g.label)).toEqual([want])); test.each([ ['1. Drops the last row (q.ts:7). Severity: Critical. Blocks: no', 'Critical'], diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index e2e9662da..fa5e40f54 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -440,7 +440,10 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change ['CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)', [['CRITICAL', 'CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)']]], ])('fix-check shape %j', (output, want) => expect(parseFindings('outside-voice', output).map(f => [f.label, f.raw])).toEqual(want)); test('the native voice\'s FIXABLE collects as CRITICAL under its role with or without a dispatch name', () => { - for (const reader of ['adversarial-native', 'adversarial-native/adversarial']) expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no').map(f => f.label)).toEqual(['CRITICAL']); + for (const reader of ['adversarial-native', 'adversarial-native/adversarial']) { + expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium').map(f => f.label)).toEqual(['CRITICAL']); + expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no').map(f => f.label)).toEqual(['Medium']); + } expect(parseFindings('outside-voice', 'FIXABLE: race in a.ts:3\nSeverity: Medium').map(f => f.label)).toEqual(['Medium']); }); test('a non-prefix Blocks: yes under a labeled finding raises it to CRITICAL', () => { @@ -529,7 +532,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change test('unlabeled detail bullets and the Severity line after them stay on the open paragraph finding', () => { const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/gsta-196-native.txt'), 'utf8'); expect(parseFindings('adversarial-native', native).map(f => [f.label, f.raw.slice(0, 12), f.raw.endsWith('| Severity: ' + (f.label === 'High' ? 'High' : 'Low') + '. Blocks: no.')])) - .toEqual([['High', '**1. A PR in', true], ['CRITICAL', '**2. A case ', true], ['CRITICAL', '**3. Local `', true], ['Low', '**4. No trai', true]]); + .toEqual([['High', '**1. A PR in', true], ['Low', '**2. A case ', true], ['Low', '**3. Local `', true], ['Low', '**4. No trai', true]]); expect(parseFindings('outside-voice', '1. FIXABLE: race in a.ts:3\n- Unlabeled sibling at b.ts:9\nSeverity: Medium').map(f => f.label)).toEqual(['FIXABLE', 'Medium']); expect(parseFindings('outside-voice', '**Retry storm in r.ts:2 (INVESTIGATE).**\n- detail\n- High: a second defect in s.ts:4\nSeverity: Medium').map(f => f.label)).toEqual(['INVESTIGATE', 'High']); expect(parseFindings('outside-voice', 'Low: cache TTL mismatch (cache.ts:4)\n\n2. Token never expires (auth.ts:9)\nSeverity: High\nBlocks: yes').map(f => [f.label, f.raw])) @@ -548,7 +551,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/gsta-196-native.txt'), 'utf8'); const r = collect(path.join(d, 'l.jsonl'), 'adversarial-native', native, path.join(d, 'm.jsonl')); expect(r.lines.map(l => [l.label, l.stated])).toEqual([ - ['High', { severity: 'High', blocks: 'no' }], ['CRITICAL', { severity: 'Low', blocks: 'no' }], ['CRITICAL', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]); + ['High', { severity: 'High', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]); expect(fs.readFileSync(path.join(d, 'm.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l).stated)).toEqual(r.lines.map(l => l.stated)); const json = collect(path.join(d, 'l2.jsonl'), 'fix-check', '{"severity":"INFORMATIONAL","path":"a.ts","line":3,"summary":"Severity: High is the wrong word here"}\n', path.join(d, 'm2.jsonl')); expect(json.lines.map(l => [l.label, l.stated])).toEqual([['INFORMATIONAL', undefined]]); @@ -584,7 +587,8 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change for (const [line, label] of kept) expect([line, parseFindings('outside-voice', line).map(f => f.label)]).toEqual([line, [label]]); const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-152-p1-adversarial-native.txt'), 'utf8'); expect(parseFindings('adversarial-native', native).map(f => f.label)).toEqual(['CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL']); - expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no').map(f => f.label)).toEqual(['CRITICAL']); + expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no').map(f => f.label)).toEqual(['Low']); + expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low.').map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('red-team', '1. Race in a.ts:3 (FIXABLE).').map(f => f.label)).toEqual(['FIXABLE']); expect(parseFindings('adversarial-native', 'No rate limit is FIXABLE (login.ts:9)').map(f => f.label)).toEqual(['CRITICAL']); }); @@ -626,7 +630,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change test('item layouts collect as on main; the later-paragraph split and indented label headings are known deferred shapes', () => { const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-ship2-p2-adversarial-native.txt'), 'utf8'); const found = parseFindings('adversarial-native', native); - expect(found.map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'Low']); + expect(found.map(f => f.label)).toEqual(['Medium', 'Low', 'Low', 'Low', 'Low', 'Low']); expect(parseFindings('outside-voice', '1. **Race on the lock** (a.ts:3)\n - the writer holds no lock\n\n Fix: take it first. Severity: High. Blocks: no.').map(f => f.label)).toEqual(['High']); expect(parseFindings('outside-voice', '1. Lock race (a.ts:3)\n\n Fix: take it first. Blocks: yes\n2. Cache drift (c.ts:4)').map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('red-team', '1. Unescaped query\n {"severity":"critical","summary":"Low-level race in q.ts"}').map(f => [f.label, f.raw])) diff --git a/test/review-fixable-class.test.ts b/test/review-fixable-class.test.ts index 010781330..7bc46dd8b 100644 --- a/test/review-fixable-class.test.ts +++ b/test/review-fixable-class.test.ts @@ -18,7 +18,7 @@ describe('native FIXABLE by review class (GST-987)', () => { // no_claim=which class a diff belongs to (lib/review-depth.ts decides) test('#141 native #4 and #6 collect at their own Severity on a code-class plan', () => { expect(parseFindings('adversarial-native', NATIVE, fixableRaises(CODE)).map(f => f.label)).toEqual(['Medium', 'Low']); - expect(parseFindings('adversarial-native', NATIVE).map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL']); + expect(parseFindings('adversarial-native', NATIVE).map(f => f.label)).toEqual(['Medium', 'Low']); }); test('gate class, any risk reason, a depth error or an unreadable plan keeps normalizer v2', () => { for (const lines of [plan('gate', 'gate-class,runtime=9'), plan('code', 'risk:money,runtime=9'), plan('code', 'risk:data,runtime=9'), plan('code', 'error:DIFF,runtime=9'), plan('code', 'runtime=9,history:fixed-blocking'), plan('code', 'runtime=9,floor:full'), plan('code', ''), plan('unknown', 'runtime=9'), [CODE[0]!], [], undefined]) @@ -47,7 +47,7 @@ describe('native FIXABLE by review class (GST-987)', () => { ['### 1. Race in a.ts:3 — FIXABLE\n\nBody.\n\n**Severity:** Medium\n**Blocks:** no\n\n### 2. Drift in b.ts:9 — FIXABLE\n\nBody.\n\n**Severity:** Low\n**Blocks:** no'], ])('a numbered finding heading keeps its body paragraph and fields (#149) %#', (output) => { expect(parseFindings('adversarial-native', output, false).map(f => f.label)).toEqual(['Medium', 'Low']); - expect(parseFindings('adversarial-native', output, true).map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL']); + expect(parseFindings('adversarial-native', output, true).map(f => f.label)).toEqual(['Medium', 'Low']); }); test('a labeled or located paragraph after a numbered heading stays its own finding (#149 joins only plain body text)', () => { for (const raises of [true, false]) { @@ -74,9 +74,9 @@ describe('native FIXABLE by review class (GST-987)', () => { }; expect(labels(['--manifest', manifest])).toEqual(['Medium', 'Low']); expect(fs.readFileSync(ledger, 'utf8')).toContain('"fixable_raises":false'); - expect(labels([])).toEqual(['CRITICAL', 'CRITICAL']); + expect(labels([])).toEqual(['Medium', 'Low']); expect(fs.readFileSync(ledger, 'utf8')).not.toContain('fixable_raises'); - expect(labels(['--manifest', foreign])).toEqual(['CRITICAL', 'CRITICAL']); + expect(labels(['--manifest', foreign])).toEqual(['Medium', 'Low']); fs.rmSync(dir, { recursive: true, force: true }); }); test('verify re-derives a code-class collect with the class it was collected under', () => { diff --git a/test/review-gst1031.test.ts b/test/review-gst1031.test.ts new file mode 100644 index 000000000..c8a4b76f0 --- /dev/null +++ b/test/review-gst1031.test.ts @@ -0,0 +1,145 @@ +import { 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 { promptHash } from '../lib/review-brief'; +import { collect, judge, ledgerBlockers, parseFindings, readJsonl, recordJudgment, summaryCandidate, verify } from '../lib/review-ledger'; +import { isBlocking, summarizeReviewLoop } from '../lib/review-scope'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const fixture = (name: string) => fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger', name), 'utf8'); +const labels = (reader: string, output: string, raises: boolean) => parseFindings(reader, output, raises).map(f => f.label); +const HEAD = '**1. The family check refuses two models in one call (check.ts:165-169). FIXABLE.**'; + +describe('one record per native finding at its stated severity (GST-1031 case 1, ruling A)', () => { + // Value: protects=a native finding whose reader states Severity and Blocks: no collects once, at that severity, on every class, and a + // heading keeps its body (located or numbered bullets, a bold subheading, a located paragraph) until its fields arrive; + // fails_when=the heading collects CRITICAL beside a separate severity record, or the gate class raises a Blocks: no finding; + // why_new=GEVA-196 (gstack-evals #153) and GSTA-150 GST-1027 looped Steps 9-11 at about 12M tokens a hit; seam=parseFindings; + // no_claim=which class a diff belongs to (lib/review-depth.ts decides) + test.each([true, false])('the GEVA-196 shape collects Medium, Low, Low with raises=%p', (raises) => { + const found = parseFindings('adversarial-native', fixture('gst-1031-native.txt'), raises); + expect(found.map(f => f.label)).toEqual(['Medium', 'Low', 'Low']); + expect(found.map(f => f.raw.startsWith('**'))).toEqual([true, true, true]); + }); + test.each([ + ['located bullets', [HEAD, 'The check runs always.', '', 'Now every judge stops:', '- the default judge clashes in policy.ts:12;', '- the other judges clash in call.ts:40.', '', 'Fix: gate it on judged.', 'Severity: Medium. Blocks: no.']], + ['numbered bullets', [HEAD, 'The check runs always.', '', 'Now every judge stops:', '1. the default judge clashes;', '2. the other judges clash.', '', 'Severity: Medium. Blocks: no.']], + ['a bold subheading', [HEAD, 'The check runs always.', '', '**Why it matters**', 'So the two models cannot share a call.', 'Severity: Medium. Blocks: no.']], + ['a located paragraph', [HEAD, 'The check runs always.', '', 'The caller in call.ts:40 picks the judge by provider.', 'Severity: Medium. Blocks: no.']], + ])('a heading keeps its body with %s: one record on both classes', (_shape, lines) => { + for (const raises of [true, false]) expect(labels('adversarial-native', (lines as string[]).join('\n'), raises)).toEqual(['Medium']); + }); + test('negative controls: no Blocks: no, Blocks: yes, no Severity field, or a labeled body line keep the raise or their own record', () => { + for (const raises of [true, false]) { + expect(labels('adversarial-native', [HEAD, 'Body.', 'Severity: Medium.'].join('\n'), raises)).toEqual(['CRITICAL']); + expect(labels('adversarial-native', [HEAD, 'Body.', 'Severity: Medium. Blocks: yes.'].join('\n'), raises)).toEqual(['CRITICAL']); + expect(labels('adversarial-native', [HEAD, 'Body.', 'Blocks: no.'].join('\n'), raises)).toEqual(['CRITICAL']); + expect(labels('adversarial-native', [HEAD, '', '- High: the caller in call.ts:40 leaks the key.', '', 'Severity: Medium. Blocks: no.'].join('\n'), raises)).toEqual(['CRITICAL', 'High']); + } + expect(labels('adversarial-native', '## FIXABLE\n1. Leak in a.ts:3\n2. Race in b.ts:4. Blocks: no', true)).toEqual(['CRITICAL', 'CRITICAL']); + }); + test('collect records the stated severity beside the one record', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031-')); + const c = collect(path.join(dir, 'l.jsonl'), 'adversarial-native', fixture('gst-1031-native.txt'), path.join(dir, 'm.jsonl')); + expect(c.lines.map(l => [l.label, l.stated])).toEqual([['Medium', { severity: 'Medium', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('a closing summary is a candidate lead judgment may dismiss on record (GST-1031 case 5)', () => { + // Value: protects=a sentence that names a severity with no file:line and no exception clause collects as a summary_candidate, which + // judge lets lead judgment lower with a reason (recorded as dismissed) so it stops counting as a blocker, while it still collects at + // its label; fails_when=GSTA-212's "nothing is CRITICAL" or GST-986 r2's "covers the CRITICAL fix" can only be cleared by a + // hand-edited row, a candidate lowered without a reason passes judge, or GST-766's exception-clause line becomes a candidate; + // why_new=both stopped /ship with NEXT: stop; seam=collect, judge, ledgerBlockers; no_claim=that a dismissal is right (it is on record) + const dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031s-')); + test('the negated and the plain summary sentence are candidates; dismissed with a reason they leave the blocker count', () => { + for (const [reader, output, id] of [['fix-check', 'None of these would fail a test or let a real consumer through on input the fix was about, so nothing is CRITICAL.\n', 'fix-check#1'], ['fix-check', fixture('gst-1031-fix-check.txt'), 'fix-check#4']]) { + const d = dir(); + const ledger = path.join(d, 'l.jsonl'); + const merged = path.join(d, 'm.jsonl'); + fs.writeFileSync(path.join(d, 'out.txt'), output!); + const c = collect(ledger, reader!, output!, merged, path.join(d, 'out.txt')); + expect(c.lines.filter(l => l.summary_candidate).map(l => [l.id, l.label])).toEqual([[id, 'CRITICAL']]); + const write = (reason?: string) => { fs.writeFileSync(path.join(d, 'j.jsonl'), c.lines.map(l => JSON.stringify({ id: l.id, severity: 'INFORMATIONAL', ...(l.id === id && reason ? { reason } : {}) })).join('\n') + '\n'); return recordJudgment(ledger, c.lines, readJsonl(path.join(d, 'j.jsonl')), path.join(d, 'j.jsonl'), merged); }; + expect(write()).toMatchObject({ ok: false, downgraded: [id], dismissed: [] }); + expect(write('closing summary: it names no defect')).toMatchObject({ ok: true, downgraded: [], dismissed: [id] }); + expect(ledgerBlockers(ledger)).toEqual([]); + expect(verify(ledger).rows.find(r => r.kind === 'JUDGE')!.status).toBe('OK'); + fs.rmSync(d, { recursive: true, force: true }); + } + }); + test('coordinated denials are candidates', () => { + for (const line of ['No CRITICAL or HIGH findings remain.', 'Nothing CRITICAL or HIGH remains.', 'No CRITICAL/HIGH findings.', 'No CRITICAL, HIGH or MEDIUM issues.', 'Nothing at CRITICAL severity remains.', 'No CRITICAL — the fix is sound.']) + expect([line, summaryCandidate(line)]).toEqual([line, true]); + }); + test('negative controls: a locus, an exception clause, a lead label, a field or a JSON finding is no candidate and keeps its label', () => { + for (const line of ['No CRITICAL issues except the token leak in log.ts:3.', 'Nothing else is CRITICAL, but the retry loop never ends.', 'The token leak in log.ts:3 is CRITICAL.', + 'The leak in bin/gstack-log:3 is CRITICAL.', 'CRITICAL: the retry loop never ends.', 'The retry loop never ends. Severity: High', 'Not checked. Blocks: no. CRITICAL', '{"severity":"CRITICAL","summary":"x"}', + 'The retry loop in the upload handler never ends, which is CRITICAL.', 'Path traversal in the download route lets any user read arbitrary files; this is CRITICAL.', + 'The session token is written to the debug log on every request, a CRITICAL leak.', 'Nothing guards the write path the failure of which is CRITICAL', + 'There is no CRITICAL lock around the refund write, so two workers double-pay.', 'The patch closes the CRITICAL window only on Linux; on macOS the race remains.']) { + expect([line, summaryCandidate(line)]).toEqual([line, false]); + expect(parseFindings('fix-check', line).length).toBe(1); + } + const lowered = judge([{ id: 'r#1', reader: 'r', label: 'CRITICAL', raw: 'No CRITICAL issues except the token leak in log.ts:3.', output_sha256: 'x' }], [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'summary' }]); + expect(lowered).toMatchObject({ ok: false, downgraded: ['r#1'] }); + }); +}); + +describe('the blocker count keys on Blocks (GST-1031 case 3)', () => { + // Value: protects=a CRITICAL its reader marked Blocks: no is not counted as a blocker by the ledger count, the row count or Step + // 9.4's classify, while Blocks: yes, an unstated Blocks and a lead promotion still block; fails_when=REVIEW_LOOP reports a + // Blocks: no note as found or unrecorded, or a Blocks: yes CRITICAL stops blocking; why_new=ggstack#52: a lane hand-added + // fixed rows to get a receipt; seam=ledgerBlockers, summarizeReviewLoop, isBlocking; no_claim=what the reader meant + const OUT = '1. Drops rows in q.ts:7. Severity: Critical. Blocks: no.\n\n2. Leaks a token in log.ts:3. Severity: Critical. Blocks: yes.\n\n3. Races in a.ts:9. Severity: Critical.\n\n4. Slows down in b.ts:2. Severity: Medium. Blocks: no.\n'; + const run = (judged?: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031b-')); + const ledger = path.join(dir, 'l.jsonl'); + const merged = path.join(dir, 'm.jsonl'); + fs.writeFileSync(path.join(dir, 'out.txt'), OUT); + collect(ledger, 'outside-voice', OUT, merged, path.join(dir, 'out.txt')); + if (judged !== undefined) { + fs.writeFileSync(path.join(dir, 'j.jsonl'), judged); + fs.appendFileSync(ledger, JSON.stringify({ event: 'judge', ok: true, judged: path.join(dir, 'j.jsonl'), judged_sha256: promptHash(judged) }) + '\n'); + } + const ids = ledgerBlockers(ledger).map(b => b.id); + fs.rmSync(dir, { recursive: true, force: true }); + return ids; + }; + test('ledgerBlockers skips a Blocks: no CRITICAL and keeps Blocks: yes, unstated and a lead promotion', () => { + expect(run()).toEqual(['outside-voice#2', 'outside-voice#3']); + const promote = ['outside-voice#1', 'outside-voice#2', 'outside-voice#3'].map(id => JSON.stringify({ id, severity: 'CRITICAL' })).join('\n') + '\n' + JSON.stringify({ id: 'outside-voice#4', severity: 'CRITICAL', reason: 'it loses rows' }) + '\n'; + expect(run(promote)).toEqual(['outside-voice#2', 'outside-voice#3', 'outside-voice#4']); + }); + test('a verb "blocks no" is no Blocks field: the located CRITICAL still counts, on the ledger and in a native finding', () => { + for (const out of ['CRITICAL: The lock in queue.ts:40 blocks no other writer, so two passes lose findings.\n', '1. **CRITICAL** — cache.ts:12 evicts on read; it blocks no request with a stale token.\n', '- CRITICAL src/auth.ts:9 token check is skipped; it blocks no attacker.\n']) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031v-')); + const c = collect(path.join(d, 'l.jsonl'), 'outside-voice', out, path.join(d, 'm.jsonl')); + expect([out, c.lines[0]!.stated?.blocks, ledgerBlockers(path.join(d, 'l.jsonl')).map(b => b.id)]).toEqual([out, undefined, ['outside-voice#1']]); + fs.rmSync(d, { recursive: true, force: true }); + } + for (const raises of [true, false]) expect(labels('adversarial-native', '**1. Race in a.ts:3. FIXABLE.**\nIt blocks no attacker.\nSeverity: Low.', raises)).toEqual(['CRITICAL']); + const q = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031y-')); + const y = collect(path.join(q, 'l.jsonl'), 'outside-voice', 'CRITICAL: auth.ts:9 skips authentication. The previous note said `Blocks: no`. Blocks yes.\n', path.join(q, 'm.jsonl')); + expect([y.lines[0]!.stated?.blocks, ledgerBlockers(path.join(q, 'l.jsonl')).map(b => b.id)]).toEqual(['yes', ['outside-voice#1']]); + fs.rmSync(q, { recursive: true, force: true }); + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031j-')); + collect(path.join(d, 'l.jsonl'), 'specialist', JSON.stringify({ severity: 'CRITICAL', path: 'lib/auth.ts', line: 9, summary: 'the old guard, which a reader marked `Blocks: no`, is gone so any caller gets admin' }) + '\n', path.join(d, 'm.jsonl')); + expect(ledgerBlockers(path.join(d, 'l.jsonl')).map(b => b.id)).toEqual(['specialist#1']); + fs.rmSync(d, { recursive: true, force: true }); + }); + test('classify and the row count key on Blocks, and a raw holding both Blocks: yes and no blocks', () => { + expect(isBlocking({ severity: 'CRITICAL', stated: { severity: 'Critical', blocks: 'no' } })).toBe(false); + expect(isBlocking({ severity: 'CRITICAL', stated: { severity: 'Critical', blocks: 'yes' } })).toBe(true); + expect(isBlocking({ severity: 'CRITICAL' })).toBe(true); + const row = (stated?: object) => ({ skill: 'review', via: 'ship', timestamp: '2026-11-02T00:00:00Z', findings: [{ path: 'q.ts', line: 7, severity: 'CRITICAL', ...(stated ? { stated } : {}) }] }); + expect(summarizeReviewLoop([row({ blocks: 'no' })], '2026-11-01T00:00:00Z').blocking_found).toBe(0); + expect(summarizeReviewLoop([row()], '2026-11-01T00:00:00Z')).toMatchObject({ blocking_found: 1, unrecorded: 1 }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031c-')); + const both = collect(path.join(dir, 'l.jsonl'), 'outside-voice', 'Leaks a token in log.ts:3. Severity: Critical. Blocks: yes. Retry is fine. Blocks: no.\n', path.join(dir, 'm.jsonl')); + expect(both.lines.map(l => l.stated?.blocks)).toEqual(['yes']); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/test/review-ledger-invariant.test.ts b/test/review-ledger-invariant.test.ts index 18b2f6744..13496e1c2 100644 --- a/test/review-ledger-invariant.test.ts +++ b/test/review-ledger-invariant.test.ts @@ -21,7 +21,8 @@ describe('parseFindings is never weaker than main (GST-976 invariant)', () => { // why_new=three GST-976 review rounds each found a shape the row tables missed; seam=parseFindings; no_claim=that main's label was // right (it is the floor), or coverage of stored private reader outputs (checked outside the repo). GST-987: the gate class (and every // risk class) keeps this rule; outside it the native voice's FIXABLE beside Blocks: no no longer counts as a raise, so such a fielded - // finding collects at its field unless a lead, a label heading or Blocks: yes raises it + // finding collects at its field unless a lead, a label heading or Blocks: yes raises it. GST-1031 ruling A: the gate and risk classes + // drop that raise too, so on every class FIXABLE raises only a finding that does not state Blocks: no test.each([['gate', true], ['code', false]] as const)('every pinned input, reader fixture and generated field shape (%s class)', (_cls, raises) => { const { entries, shapes } = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/main-labels.json'), 'utf8')); const { bodies, fields: tails, joins, readers, generated } = shapes; @@ -37,7 +38,7 @@ describe('parseFindings is never weaker than main (GST-976 invariant)', () => { findings++; const fields = [...f.raw.matchAll(FIELD)].map(m => (m[1] ?? m[2])!); const single = fields.length === 1 && fields[0]!.match(LABEL)!.length === 1 ? fields[0]!.match(LABEL)![0] : undefined; - const raised = /Blocks:?\**:?\s*\**\s*yes/i.test(f.raw) || (e.reader.startsWith('adversarial-native') && /FIXABLE/.test(f.raw) && (raises || !/Blocks:?\**:?\s*\**\s*no\b/i.test(f.raw))) || (raises ? LEAD : LEAD_NO_FIXABLE).test(f.raw) || HEADING.test(output); + const raised = /Blocks:?\**:?\s*\**\s*yes/i.test(f.raw) || (e.reader.startsWith('adversarial-native') && /FIXABLE/.test(f.raw) && !/Blocks:?\**:?\s*\**\s*no\b/i.test(f.raw)) || (raises ? LEAD : LEAD_NO_FIXABLE).test(f.raw) || HEADING.test(output); const ok = single === undefined ? (rank(f.label) ?? 0) >= (rank(e.main[i]) ?? 0) : (rank(f.label) ?? 0) >= (rank(single) ?? 0) && (raised || (rank(f.label) ?? 0) === (rank(single) ?? 0)); if (!ok) bad.push(`${e.reader} main=${e.main[i]} got=${f.label} field=${single ?? '-'} ${JSON.stringify(f.raw.slice(0, 100))}`); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 2e8ec1126..57a9512e5 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -706,6 +706,21 @@ describe('Capy pass program (fake host, real bins)', () => { expect(v).toMatch(/^OUTPUT fix-check MATCH /m); }, 240_000); + // Value: protects=decision 3.5's fix-check takes a review-fix-check start token even when the parent passes the Capy skill's + // logStart ["review"], so its row is never a pass and a review_loop_exception can follow it; fails_when=the fix-check pass logs a + // review start; why_new=ggstack#52 (GST-1031): a scoped fix-check logged as review counted as a second pass; seam=the pass program; + // no_claim=what the parent writes in the row + test('a fix-check pass given logStart ["review"] takes the review-fix-check start', async () => { + const { dir, env } = repo(); + const files = path.join(dir, '.state/fc'); + fs.mkdirSync(files, { recursive: true }); + const fc: Record = {}; + 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']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); } + const res = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fcr'), pass: 'x52fc', planFlags: ['--pass', '1', '--last', 'full', '--fixed-below'], fixCheck: fc, logStart: ['review'] }); + expect(res).toMatch(/REVIEW_LOG_START: review-fix-check token=\S+/); + expect(res).not.toMatch(/REVIEW_LOG_START: review token=/); + }, 240_000); + test('Step 11 records one start per row it logs, even when two rows share a skill (native and outside adversarial-review)', async () => { const { dir, env } = repo(); const out = path.join(dir, '.state/run/p1'); diff --git a/test/ship-release.test.ts b/test/ship-release.test.ts index c6011e974..8b7dc72b2 100644 --- a/test/ship-release.test.ts +++ b/test/ship-release.test.ts @@ -308,6 +308,29 @@ describe('gstack-ship-release push: branch names', () => { }); }); +describe('GST-1031: a non-behavioral harness re-pin after review is release metadata', () => { + // Value: protects=a scripts/harness-version.json edit after review that only records changed harness files as non-behavioral at the same + // version routes as metadata, so a clean round 2 is not sent back through 5-11.5, while a version bump, a pattern change or an entry + // without its reason stays behavior; fails_when=the re-pin stops finish, or a bump or bare re-pin routes as metadata; + // why_new=GSTA-150 GST-1027 (icm_01M6ERWWBZ3VWXZY07F2W5ZSFB); seam=route; no_claim=that the recorded reason is true + const pin = (version: number, entry: Record, patterns = ['test/helpers/x.ts']) => JSON.stringify({ version, patterns, files: { 'test/helpers/x.ts': entry } }) + '\n'; + const finish = (after: string) => { + const f = fx(); + f.write({ 'scripts/harness-version.json': pin(6, { blob: 'a'.repeat(40), since: 6 }) }); f.commit('pin'); + const reviewed = wtree(f); + const post = snapshot(f); + f.write({ 'scripts/harness-version.json': after }); f.commit('re-pin'); + return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]).stdout; + }; + test('a non-behavioral re-pin is metadata; a bump, a pattern change or an entry without its reason is behavior', () => { + const repin = finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' })); + expect(lineOf(repin, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/); + expect(repin).not.toContain('STOP: behavior'); + for (const after of [pin(7, { blob: 'b'.repeat(40), since: 7 }), pin(6, { blob: 'b'.repeat(40), since: 6 }), pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**'])]) + expect(finish(after)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)'); + }); +}); + describe('GSTA-167 part 4a: the post-review route stays path-only', () => { // Value: protects=F1/F2, a data file edited after review sends the ship back through review even when no code names it, // because the route never applies the data rule; fails_when=a JSON edit after review takes the docs-rechecked route; seam=classifyPath