Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/trend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]);
Expand Down
28 changes: 21 additions & 7 deletions src/trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IssueSnapshot> {
const out = new Map<string, IssueSnapshot>();
/** Group issues by fingerprint, keeping every occurrence (a multiset, as in delta.ts). */
function issueMultiset(report: VibeReport): Map<string, IssueSnapshot[]> {
const out = new Map<string, IssueSnapshot[]>();
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<string, IssueSnapshot[]>, b: Map<string, IssueSnapshot[]>): 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" : "=";
Expand Down