fix: GST-987 round-2 fixes: FIXABLE relaxes only beside Blocks: no; raw FIXABLE heading; unreadable manifest keeps v2

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 14:09:07 +00:00
1 parent 9d910f9625
commit 521ef5b3fe
6 files changed
+17 -11

No files matched your search

+2 -1
View File
@@ -330,7 +330,8 @@ function main(): number {
return EXIT.ok;
}
const mf = flag('--manifest');
const plan = mf ? readManifest(mf) : undefined;
let plan: ReturnType<typeof readManifest> | undefined;
try { plan = mf ? readManifest(mf) : undefined; } catch (e) { process.stderr.write(`gstack-review-dispatch collect: ${(e as Error).message}; native FIXABLE collects as CRITICAL\n`); }
const r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable, tree, fixableRaises(plan && path.resolve(plan.ledger) === path.resolve(ledger) ? plan.plan_lines : undefined));
if (tree && !tree.ok) {
if (mf) { const m = readManifest(mf); markReader(m, reader, 'failed', { output: outFile, error: `reviewed another tree: ${tree.reported}` }); writeManifest(mf, m); }
+1 -1
View File
@@ -2,4 +2,4 @@
### Fixed
- On a review whose class is not gate and that carries no risk reason (security, secrets, auth, money, user data), a native adversarial finding marked FIXABLE with its own `Severity: Medium` or `Severity: Low` now collects at that severity instead of CRITICAL. Gate and risk reviews, depth errors and any collect without a pass manifest keep collecting FIXABLE as CRITICAL, and a FIXABLE finding with no Severity field still collects as CRITICAL. (GST-987)
- On a code or docs review with no risk reason (security, secrets, auth, money, user data), no depth error and no history or floor escalation, a native adversarial finding marked FIXABLE that states its own `Severity: Medium` or `Severity: Low` and `Blocks: no` now collects at that severity instead of CRITICAL. Gate and risk reviews, and any collect without this pass's manifest, keep collecting FIXABLE as CRITICAL, and so does a FIXABLE finding without `Blocks: no` or without a Severity field. (GST-987)
+2 -2
View File
@@ -533,8 +533,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
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=<n>` 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; a native finding with no `Severity` field, or one whose field names only FIXABLE or INVESTIGATE, still
collects as CRITICAL (GST-987). An unnumbered, unlabeled bullet with no file:line
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
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
+6 -4
View File
@@ -58,6 +58,7 @@ const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+\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 = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*yes(?![A-Za-z0-9])/i;
const BLOCKS_NO = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*no(?![A-Za-z0-9])/i;
const ALL_LABELS = new RegExp(ANY_LABEL.source, 'g');
/** A file:line locus ("auth.ts:9"): prose naming one after a finding's fields starts the next finding. */
const LOCUS = /[\w./-]+\.\w+:\d+/;
@@ -102,8 +103,8 @@ 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 review outside the gate class with no risk reason, GST-987) a native finding's own Severity field decides
* and FIXABLE beside it raises nothing; a native finding with no Severity field still collects FIXABLE as CRITICAL.
* 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.
*/
export function parseFindings(reader: string, output: string, fixableRaises = true): Array<{ label: string; raw: string }> {
type Finding = { label: string; raw: string; fields: Set<string>; list: boolean; lead?: string };
@@ -152,7 +153,7 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr
continue;
}
const heading = LABEL_HEADING.exec(t);
if (heading) { section = named(heading[1]!); current = null; head = ''; continue; }
if (heading) { section = fixableRaises ? named(heading[1]!) : heading[1]!; current = null; head = ''; continue; }
if (!indented && HEADING.test(t)) { section = null; current = null; }
if (t.startsWith('{')) {
try {
@@ -178,7 +179,8 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr
}
if (!fresh && !current && (!LIST_ITEM.test(head) || locus)) head = t;
}
return out.map(({ label, raw }) => ({ label, raw }));
const unblocked = (label: string, raw: string) => fixableRaises || role !== 'adversarial-native' || !(label.toUpperCase() === 'FIXABLE' || /(?<![A-Za-z0-9])FIXABLE(?![A-Za-z0-9])/.test(raw)) || BLOCKS_NO.test(raw);
return out.map(({ label, raw }) => ({ label: unblocked(label, raw) ? label : 'CRITICAL', raw }));
}
/**
+3
View File
@@ -31,6 +31,9 @@ describe('native FIXABLE by review class (GST-987)', () => {
expect(parseFindings('adversarial-native', 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', false).map(f => f.label)).toEqual(['Medium']);
expect(parseFindings('adversarial-native', 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: yes', false).map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('adversarial-native', '1. Race in a.ts:3. Severity: FIXABLE', false).map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('adversarial-native', '1. FIXABLE: race in store.ts:40. Severity: High', false).map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('adversarial-native', '1. FIXABLE: race in store.ts:40\nSeverity: High\nBlocks: no', false).map(f => f.label)).toEqual(['High']);
expect(parseFindings('adversarial-native', '## FIXABLE\n1. Leak in a.ts:3. Severity: Low. Blocks: no\n2. Race in b.ts:4', false).map(f => f.label)).toEqual(['Low', 'CRITICAL']);
});
test('collect reads the class from --manifest and defaults to the gate class without one', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst987-'));
+3 -3
View File
@@ -20,8 +20,8 @@ describe('parseFindings is never weaker than main (GST-976 invariant)', () => {
// its weaker label (b3ddd6c) or a field whose clause runs on stops counting and a body P0 raises the finding (f3810d4);
// 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 no longer counts as a raise, so a fielded finding collects at its
// field unless a lead, a label heading or Blocks: yes raises it
// 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
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 +37,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) || (raises && e.reader.startsWith('adversarial-native') && /FIXABLE/.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) && (raises || !/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))}`);