fix(autoplan): CAP titles on different paths never match by title in reconcile or confirm rounds (wave review)

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-01 11:00:46 +00:00
1 parent d1909fac57
commit f5e603c75e
3 files changed
+25 -7

No files matched your search

+2 -2
View File
@@ -13,7 +13,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { jaccard, tokens } from './autoplan-reconcile';
import { titleScore } from './autoplan-reconcile';
import { readDecisions, type PhaseState, type RunState } from './autoplan-state';
export interface DiffLine { op: ' ' | '+' | '-'; text: string; a?: number; b?: number }
@@ -135,7 +135,7 @@ export function annotateConfirm(state: RunState, phase: string, rows: FindingRow
const isPlan = (f: FindingRow) => f.file === ctx.planName;
let correlated = 0, settledCount = 0;
for (const row of rows as Array<FindingRow & { correlates?: string[]; settled_by?: string }>) {
const same = earlier.filter(e => jaccard(tokens(e.title), tokens(row.title)) >= 0.6 || (!!e.file && !isPlan(e) && e.file === row.file && e.line === row.line));
const same = earlier.filter(e => titleScore(e.title, row.title) >= 0.6 || (!!e.file && !isPlan(e) && e.file === row.file && e.line === row.line));
if (!same.length) continue;
row.correlates = same.map(e => e.id);
correlated++;
+9 -4
View File
@@ -211,6 +211,13 @@ export function jaccard(a: Set<string>, b: Set<string>): number {
for (const t of a) if (b.has(t)) inter++;
return inter / (a.size + b.size - inter);
}
const capPath = (title: string) => /^\s*CAP[46]\s*:([^:]*):/i.exec(title)?.[1]!.trim();
/** Title similarity. Two CAP-coded titles on different paths name different problems, so they never match. */
export function titleScore(a: string, b: string): number {
const pa = capPath(a), pb = capPath(b);
if (pa !== undefined && pb !== undefined && pa !== pb) return 0;
return jaccard(tokens(a), tokens(b));
}
const level = (s: Severity) => SEVERITIES.indexOf(s);
/** A remedy compared as words: case, punctuation and spacing ignored. */
export const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
@@ -265,12 +272,11 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
continue;
}
const near = (n: FindingRow) => !!o.file && o.line !== undefined && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line) <= 3;
const ot = tokens(o.title);
let match: FindingRow | undefined;
let best = 0;
for (const n of native) {
if (used.has(n.id) || (o.file && n.file && o.file !== n.file)) continue;
const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0);
const score = titleScore(o.title, n.title) + (near(n) ? 0.01 : 0);
if (score >= 0.5 && score > best) { best = score; match = n; }
}
const titled = !!match;
@@ -294,9 +300,8 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
}
// Plan reviews name a file without a line: same file, a line missing on either side, once every title and line match is placed.
for (const [i, o] of later) {
const ot = tokens(o.title);
const match = native.filter(n => !used.has(n.id) && n.file === o.file && (o.line === undefined || n.line === undefined))
.reduce<FindingRow | undefined>((b, n) => (!b || jaccard(ot, tokens(n.title)) > jaccard(ot, tokens(b.title)) ? n : b), undefined);
.reduce<FindingRow | undefined>((b, n) => (!b || titleScore(o.title, n.title) > titleScore(o.title, b.title) ? n : b), undefined);
if (!match) continue;
used.add(match.id);
rows[i] = { outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same file, agreement unverified (${o.file}): ${match.title}`, basis: 'file', severity: { outside: o.severity, native: match.severity } };
+14 -1
View File
@@ -7,7 +7,7 @@
*/
import { describe, expect, test } from 'bun:test';
import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json';
import { reconcile } from '../lib/autoplan-reconcile';
import { reconcile, titleScore } from '../lib/autoplan-reconcile';
import { challengeDefault } from '../lib/autoplan-gate';
const pair = (name: string) => fixture.pairs.find(p => p.name.startsWith(name))! as any;
@@ -104,4 +104,17 @@ describe('plan reviews name a file without a line (GBRA-223 claim 1)', () => {
const b = row('outside', 1, { severity: 'High', title: 'CAP6:lib/telemetry-sync.ts:recovery no retry bound on network failure', file: 'lib/telemetry-sync.ts' });
expect(reconcile([a], [b]).rows[0]).toMatchObject({ disposition: 'new', native_id: null });
});
test('two CAP titles on different paths never match by title, even when both findings sit on the plan file', () => {
const A = 'CAP6:bin/gstack-eval-plan pilot start:fix no fix line names the command';
const B = 'CAP6:bin/gstack-outside-voice run:fix no fix line names the command';
expect(titleScore(A, B)).toBe(0);
expect(titleScore(A, A)).toBe(1);
expect(titleScore(A, 'no fix line names the command')).toBe(1);
const n = row('native', 1, { severity: 'High', title: A, file: 'plan.md', resolution: 'name the command' });
const o = row('outside', 1, { severity: 'High', title: B, file: 'plan.md', resolution: 'name the command' });
expect(reconcile([n], [o]).rows[0]).toMatchObject({ basis: 'file', disposition: 'unverified' });
const bare = (x: any) => { const { file, ...rest } = x; return rest; };
expect(reconcile([bare(n)], [bare(o)]).rows[0]).toMatchObject({ disposition: 'new', native_id: null });
});
});