From 35dcb44b473ae1adb570fb0fb5b4218b5de2e7e7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 14:55:06 +0000 Subject: [PATCH] fix: a paragraph under a numbered finding heading joins it, so its fields attach (gstack-evals #149, GST-987) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/review-ledger.ts | 10 +++++++++- test/review-fixable-class.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index 0123bbf46..1ea68b45b 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -55,6 +55,7 @@ const ANY_LABEL = new RegExp(`(? ((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 = /(? { - type Finding = { label: string; raw: string; fields: Set; list: boolean; lead?: string; fixable?: boolean }; + type Finding = { label: string; raw: string; fields: Set; list: boolean; lead?: string; fixable?: boolean; heading?: boolean }; const out: Finding[] = []; let section: string | null = null; let current: Finding | null = null; @@ -163,6 +164,12 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr } const detail = listItem && /^[-*+]\s/.test(t) && !locus && !label && !section && !!current && !current.list && !current.fields.size; if (detail && !blocks) { current!.raw += ` | ${t}`; continue; } + if (afterGap && current?.heading && !LIST_ITEM.test(t) && !HEADING.test(t) && !NUMBERED_HEAD.test(t)) { + current.raw += ` | ${t}`; + if (blocks) current.label = 'CRITICAL'; + else if (label) current.label = stronger(current.label, label); + continue; + } const fresh = (listItem && !detail) || (!indented && !detail && (t.startsWith('**') || afterGap)) || (!!current?.fields.size && locus); if (fresh) { current = null; head = t; } const inherited = section && listItem ? section : undefined; @@ -170,6 +177,7 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr if (found) { current = push(blocks ? 'CRITICAL' : found, t, [], strongestOf([leadOf(t), inherited, fieldOf(t)])); current.fixable = inherited?.toUpperCase() === 'FIXABLE'; + current.heading = !indented && NUMBERED_HEAD.test(t); if (!indented) head = t; continue; } diff --git a/test/review-fixable-class.test.ts b/test/review-fixable-class.test.ts index ce1075e00..7e233ffbe 100644 --- a/test/review-fixable-class.test.ts +++ b/test/review-fixable-class.test.ts @@ -37,6 +37,18 @@ describe('native FIXABLE by review class (GST-987)', () => { expect(parseFindings('adversarial-native', '**FIXABLE**\n- Leak in a.ts:3\n Severity: Medium', false).map(f => f.label)).toEqual(['CRITICAL']); expect(parseFindings('adversarial-native', 'FIXABLE: race in a.ts:3. Severity: Low. Blocks: no-op path only', false).map(f => f.label)).toEqual(['CRITICAL']); }); + // Value: protects=a paragraph under a numbered finding heading joins that heading's finding, so the reader's later Severity and Blocks + // fields attach to it instead of splitting it into a CRITICAL heading plus a body item (gstack-evals #149); fails_when=any of the three + // shapes collects 4 findings or the heading alone keeps CRITICAL on a code-class plan; why_new=#149 split native findings in both + // classes; seam=parseFindings; no_claim=how unnumbered headings group + test.each([ + ['### 1. FIXABLE: race in a.ts:3\n\nThe body explains it.\n\nSeverity: Medium, Blocks: no\n\n### 2. FIXABLE: drift in b.ts:9\n\nMore body.\n\nSeverity: Low, Blocks: no'], + ['**1. Race in a.ts:3** FIXABLE\n\nBody.\n\nSeverity: Medium, Blocks: no\n\n**2. Drift in b.ts:9** FIXABLE\n\nBody.\n\nSeverity: Low, Blocks: no'], + ['### 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']); + }); test('collect reads the class from --manifest and defaults to the gate class without one', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst987-')); const out = path.join(dir, 'native.txt');