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" : "=";