mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
honor not-ready review verdicts with advisory findings
The classifier now honors the last recognized not-ready verdict regardless of parsed severity, returning findings and exit code 3 while retaining the highest severity in the summary. Advisory findings with a ready verdict or no verdict keep their existing behavior. Regression coverage and troubleshooting documentation describe this behavior across the execution, structured, and review gates. Fixes #3329
This commit is contained in:
1 parent
00100f1598
commit
73da98c7c1
3 files changed
+80
-5
No files matched your search
@@ -170,8 +170,10 @@ the readiness dashboard and the PR body, and never count it as a pass.
|
||||
findings.
|
||||
- **Every review gate.** These rules apply to every gate that summarises a
|
||||
review, including the `execution` gate behind `/codex` consult and challenge.
|
||||
A reply whose last `Verdict:` line says `not ready` but carries no readable
|
||||
severity prints `FINDINGS: unspecified` and exits 3 like a blocking finding.
|
||||
A reply whose last `Verdict:` line says `not ready` is `findings` and exits
|
||||
3 even when readable advisory severities (P2 or P3) were parsed; `FINDINGS:`
|
||||
names the highest one. A not-ready reply with no readable severity still
|
||||
prints `FINDINGS: unspecified` and exits 3.
|
||||
- **No findings.** A review with no tag and no label is `clean` only when its
|
||||
conclusion says so: the last sentence (or its last ", and" clause), or the
|
||||
`because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`,
|
||||
|
||||
@@ -263,7 +263,8 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
|
||||
}
|
||||
if (!reviewed) return result(blocking ? 'findings' : 'clean');
|
||||
const unparsed = firstUnparsed(code);
|
||||
const notReady = !findings.highest && verdictNotReady(plainCode);
|
||||
// A last explicit not-ready blocks even when the only parsed severities are advisory.
|
||||
const notReady = verdictNotReady(plainCode);
|
||||
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
|
||||
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
|
||||
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) {
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* FINDINGS: none` for all of them. The fixtures in
|
||||
* test/fixtures/outside-review-severity/codex-3105/ are synthesized from the
|
||||
* forms the issue documents (it describes 13 replies but does not include them).
|
||||
* #3329: that same gate then printed `clean` for a parsed advisory finding
|
||||
* whose last `Verdict:` line says `not ready`. `bare-dash-p2.txt` is unchanged.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyOutsideReview, type OutsideGate } from '../lib/outside-review-result';
|
||||
import { classifyOutsideReview, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result';
|
||||
import { GATE_OUTCOMES } from '../lib/gate-outcomes';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -26,7 +28,7 @@ describe('#3105 Codex reply shapes under the execution gate', () => {
|
||||
for (const [name, status, stdout] of [
|
||||
['plan-bold-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['diff-numbered-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['bare-dash-p2.txt', 0, 'VERDICT: clean\nFINDINGS: P2\n'],
|
||||
['bare-dash-p2.txt', 3, 'VERDICT: findings\nFINDINGS: P2\n'],
|
||||
['ready-one-p3.txt', 0, 'VERDICT: clean\nFINDINGS: P3\n'],
|
||||
['table-p1.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'],
|
||||
['not-ready-unlabeled.txt', 3, 'VERDICT: findings\nFINDINGS: unspecified\n'],
|
||||
@@ -112,3 +114,73 @@ describe('#3105 severity forms are read by every gate that summarises a review',
|
||||
expect(classifyOutsideReview({ text: proposal, gate: 'proposal' }).verdict).toBe('clean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3329 a last not-ready Verdict blocks even with advisory findings', () => {
|
||||
const ISSUE = '1. **P2 — The docs lag the new flag.**\n2. **P3 — Rename the helper.**\nVerdict: not ready (1 P1/P2)';
|
||||
const P2 = '1. **P2 — The docs lag the new flag.**';
|
||||
const textFor = (gate: OutsideGate, text: string) => gate === 'review' ? `${text}\n${REC}` : text;
|
||||
const see = (text: string, gate: OutsideGate) => {
|
||||
const r = classifyOutsideReview({ text: textFor(gate, text), gate });
|
||||
return { verdict: r.verdict as string, highest: r.findings.highest as string | null, reason: (r.reason ?? null) as string | null, unparsed: r.unparsed ?? null };
|
||||
};
|
||||
|
||||
test('numbered bold P2 plus P3 with not ready is findings/P2 on execution, structured and review', () => {
|
||||
for (const gate of ['execution', 'structured', 'review'] as const)
|
||||
expect({ gate, ...see(ISSUE, gate) }).toEqual({ gate, verdict: 'findings', highest: 'P2', reason: null, unparsed: null });
|
||||
expect(validateOutsideReview(ISSUE, 'structured')).toEqual({ completed: true, gate: 'fail' });
|
||||
});
|
||||
|
||||
test('P3-only and unlabeled not-ready replies block; P0/P1 block even when the last Verdict says ready', () => {
|
||||
for (const gate of ['execution', 'structured', 'review'] as const) {
|
||||
expect({ gate, ...see('1. **P3 — Rename the helper.**\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: 'P3', reason: null, unparsed: null });
|
||||
expect({ gate, ...see('The retry path can double-send.\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: null, reason: null, unparsed: null });
|
||||
}
|
||||
expect(see('1. **P1 — The cap is ignored.**\nVerdict: ready', 'review')).toMatchObject({ verdict: 'findings', highest: 'P1' });
|
||||
expect(see('**P0 — Secrets reach the log.**\nVerdict: ready', 'execution')).toMatchObject({ verdict: 'findings', highest: 'P0' });
|
||||
});
|
||||
|
||||
test('advisory P2/P3 with ready or no Verdict stay clean', () => {
|
||||
for (const text of [
|
||||
`${P2}\n2. **P3 — Rename the helper.**\nVerdict: ready`,
|
||||
`${P2}\n2. **P3 — Rename the helper.**`,
|
||||
'1. **P3 — Rename the helper.**\nVerdict: ready (1 P3)',
|
||||
]) {
|
||||
const r = see(text, 'execution');
|
||||
expect({ text, verdict: r.verdict, highest: r.highest }).toEqual({ text, verdict: 'clean', highest: text.includes('P2') ? 'P2' : 'P3' });
|
||||
}
|
||||
expect(see(`${P2}\nVerdict: ready`, 'review').verdict).toBe('clean');
|
||||
expect(validateOutsideReview(`${P2}\nVerdict: ready`, 'structured')).toEqual({ completed: true, gate: 'pass' });
|
||||
});
|
||||
|
||||
test('the last recognized Verdict wins, including supported formatting; quotes do not', () => {
|
||||
expect(see(`${P2}\nVerdict: ready\nVerdict: not ready (1 P1/P2)`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: not ready (1 P1/P2)\nVerdict: ready`, 'execution').verdict).toBe('clean');
|
||||
for (const verdictLine of ['**Verdict:** not ready', '- Verdict: not ready', '### Verdict: not ready'])
|
||||
expect({ verdictLine, ...see(`${P2}\n${verdictLine}`, 'execution') }).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: not ready\n### Verdict: ready`, 'execution').verdict).toBe('clean');
|
||||
const fence = ['```', 'Verdict: not ready (2 P2)', '```'].join('\n');
|
||||
expect(see(`${P2}\nVerdict: ready\n${fence}`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`${fence}\n${P2}\nVerdict: not ready`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' });
|
||||
expect(see(`${P2}\nVerdict: ready\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`${P2}\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean');
|
||||
expect(see(`No issues found.\n${fence}`, 'review').verdict).toBe('clean');
|
||||
});
|
||||
|
||||
test('parsed P2 plus an unparsed label and not-ready still blocks and keeps the diagnostic', () => {
|
||||
const text = `${P2}\n## P1/P2 findings\nVerdict: not ready`;
|
||||
expect(see(text, 'execution')).toEqual({ verdict: 'findings', highest: 'P2', reason: null, unparsed: 'line 2: "## P1/P2 findings"' });
|
||||
expect(see(`${P2}\n## P1/P2 findings`, 'structured')).toMatchObject({ verdict: 'unverified', reason: 'unparsed_severity', unparsed: 'line 2: "## P1/P2 findings"' });
|
||||
});
|
||||
|
||||
test('missing markers and failed execution stay unavailable; proposal and spec ignore the verdict', () => {
|
||||
expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready`, gate: 'review' }).reason).toBe('missing_markers');
|
||||
const failed = classifyOutsideReview({ text: textFor('review', `${P2}\nVerdict: not ready`), gate: 'review', exit: 1, stderr: 'fatal: provider exploded' });
|
||||
expect(failed.verdict).toBe('unavailable');
|
||||
expect(failed.reason).toBe('execution_failed');
|
||||
expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready\n${REC}`, gate: 'proposal' }).verdict).toBe('clean');
|
||||
const passing = classifyOutsideReview({ text: `SCORE: 8\nAMBIGUITIES: NONE\n${P2}\nVerdict: not ready`, gate: 'spec' });
|
||||
expect([passing.verdict, passing.score]).toEqual(['clean', 8]);
|
||||
const failing = classifyOutsideReview({ text: 'SCORE: 6\nAMBIGUITIES: NONE\nVerdict: ready', gate: 'spec' });
|
||||
expect([failing.verdict, failing.score]).toEqual(['findings', 6]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user