From 685299bce841a9acf46f01841abdb04921e9ae28 Mon Sep 17 00:00:00 2001 From: Sergey Ivochkin Date: Mon, 5 Oct 2026 13:58:22 +1100 Subject: [PATCH] fix(trend): count repeated fingerprints as a multiset (#97) trend.ts collapsed issues into a Map keyed by fingerprint, so two identical findings counted once and fixing one of them reported nothing. Count occurrences per fingerprint, as delta.ts already does, and report the surplus on each side as introduced / fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SmtSK7qDTyXhimJ9grap88 --- src/trend.test.ts | 26 ++++++++++++++++++++++++++ src/trend.ts | 28 +++++++++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/trend.test.ts b/src/trend.test.ts index 59f43e6..65287a8 100644 --- a/src/trend.test.ts +++ b/src/trend.test.ts @@ -68,6 +68,32 @@ describe("computeTrend", () => { expect(trend.fixed?.[0]).toMatchObject({ rule: "old", file: "src/a.ts" }); }); + it("counts repeated fingerprints as a multiset: fixing one of two identical findings reports one fixed", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + const dup = { severity: "warning" as const, rule: "no-any", message: "Avoid any", file: "src/a.ts" }; + const prev = makeReport(80, [{ name: "types", score: 80, issues: 0 }]); + prev.checks[0].issues = [ + { ...dup, line: 3 }, + { ...dup, line: 9 }, + ]; + writeFileSync(join(dir, "report.json"), JSON.stringify(prev)); + + const curr = makeReport(81, [{ name: "types", score: 81, issues: 0 }]); + curr.checks[0].issues = [{ ...dup, line: 3 }]; + + const trend = computeTrend(curr, dir)!; + expect(trend.fixedIssues).toBe(1); + expect(trend.newIssues).toBe(0); + expect(trend.fixed).toHaveLength(1); + expect(trend.fixed?.[0]).toMatchObject({ rule: "no-any", file: "src/a.ts" }); + + const back = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + writeFileSync(join(back, "report.json"), JSON.stringify(curr)); + const reverse = computeTrend(prev, back)!; + expect(reverse.newIssues).toBe(1); + expect(reverse.fixedIssues).toBe(0); + }); + it("detects regressions", () => { const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); const prev = makeReport(90, [{ name: "lint", score: 90, issues: 1 }]); diff --git a/src/trend.ts b/src/trend.ts index 13359cd..15f7066 100644 --- a/src/trend.ts +++ b/src/trend.ts @@ -42,27 +42,41 @@ export function computeTrend(report: VibeReport, outputDir: string): TrendDelta }); } - const prevIssueMap = issueMap(prev); - const currIssueMap = issueMap(report); - const introduced = [...currIssueMap.entries()].filter(([fp]) => !prevIssueMap.has(fp)).map(([, issue]) => issue); - const fixed = [...prevIssueMap.entries()].filter(([fp]) => !currIssueMap.has(fp)).map(([, issue]) => issue); + const prevIssues = issueMultiset(prev); + const currIssues = issueMultiset(report); + const introduced = surplus(currIssues, prevIssues); + const fixed = surplus(prevIssues, currIssues); const newIssues = introduced.length; const fixedIssues = fixed.length; return { scoreDelta, checkDeltas, newIssues, fixedIssues, introduced, fixed, prevTimestamp: prev.timestamp }; } -function issueMap(report: VibeReport): Map { - const out = new Map(); +/** Group issues by fingerprint, keeping every occurrence (a multiset, as in delta.ts). */ +function issueMultiset(report: VibeReport): Map { + const out = new Map(); for (const check of report.checks) { for (const issue of check.issues) { const fp = readIssueFingerprint(check.name, issue); - out.set(fp, issueSnapshot(check.name, issue)); + const list = out.get(fp); + const snap = issueSnapshot(check.name, issue); + if (list) list.push(snap); + else out.set(fp, [snap]); } } return out; } +/** Occurrences in `a` beyond the count of the same fingerprint in `b`. */ +function surplus(a: Map, b: Map): IssueSnapshot[] { + const out: IssueSnapshot[] = []; + for (const [fp, list] of a) { + const other = b.get(fp)?.length ?? 0; + if (list.length > other) out.push(...list.slice(other)); + } + return out; +} + /** Render trend delta as terminal-friendly string with sparkline. */ export function formatTrend(trend: TrendDelta, historyScores?: number[]): string { const arrow = trend.scoreDelta > 0 ? "\u2191" : trend.scoreDelta < 0 ? "\u2193" : "=";