diff --git a/CHANGELOG.md b/CHANGELOG.md index 623aa48..a05315b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### A check that did not run is no longer scored, diffed or counted +A not-run check carries a placeholder `score: 100` and a crashed runner a +placeholder `0`/F. Deltas and trends subtracted them from real scores, so +installing a linter read as `lint: 100 → 72 (-28)`, uninstalling it as `+36`, +and a crash as `-72` (#107). +- **Changed**: when either side of a comparison did not run (skipped, + unavailable), its runner crashed, or the check is absent, the delta, the + trend, the PR comment, the delta markdown and the Actions and Trends pages + show a status transition, e.g. `lint: unavailable → 72` or + `72 → failed (runner error)`, instead of a numeric delta. The Trends page + plots only the scans in which a check ran; a single check's card shows the + full crash reason. +- **Changed**: issues of such a check are not compared, so a crash, an + uninstalled tool or a dropped check no longer reports its issues as fixed + (or, the other way round, as new). + +**Note for consumers** of `@vibecodeqa/cli/core`: +- `ScanDelta.checks[].before` / `.after` (`CheckDelta`) are now + `number | null`: `null` for a side that did not run, crashed, or is absent; + the check then carries a `transition` and `delta: 0`. TypeScript callers + that treat them as `number` need a null check. +- Internally (not exported from `core`), `TrendDelta.checkDeltas[].prev` / + `.curr` change the same way, and the unused `trendHTML` is removed. + ### Issue fingerprints v2 — a finding keeps its identity when its number moves Fingerprints hashed check + rule + path + message, so a message carrying a measurement ("AdminLayout: 65 lines (max 60)") re-identified the finding every diff --git a/src/analyzer-snapshot.test.ts b/src/analyzer-snapshot.test.ts index 5180424..713c720 100644 --- a/src/analyzer-snapshot.test.ts +++ b/src/analyzer-snapshot.test.ts @@ -85,3 +85,28 @@ describe("buildAnalyzerSnapshots", () => { }); }); }); + +describe("buildAnalyzerSnapshots not-run checks (#107)", () => { + it("omits score for skipped and unavailable checks", () => { + const snaps = buildAnalyzerSnapshots([ + check({ name: "lint", score: 100, details: { skipped: true, unavailable: true, status: "unavailable" } }), + check({ name: "react", score: 100, details: { skipped: true, status: "skipped" } }), + check({ name: "ai-review", score: 100, details: { comingSoon: true } }), + check({ name: "types", score: 88, details: { status: "passed" } }), + ]); + expect(snaps.map((s) => s.status)).toEqual(["unavailable", "skipped", "unavailable", "passed"]); + for (const s of snaps.slice(0, 3)) expect(s).not.toHaveProperty("score"); + expect(snaps[3].score).toBe(88); + expect(JSON.parse(JSON.stringify(snaps[0]))).not.toHaveProperty("score"); + }); +}); + +describe("buildAnalyzerSnapshots crashed runner (#107)", () => { + it("keeps status failed but omits the placeholder 0", () => { + const [snap] = buildAnalyzerSnapshots([ + check({ name: "lint", score: 0, details: { skipped: true, status: "failed", reason: "runner error: boom" } }), + ]); + expect(snap.status).toBe("failed"); + expect(snap).not.toHaveProperty("score"); + }); +}); diff --git a/src/analyzer-snapshot.ts b/src/analyzer-snapshot.ts index 4644a94..657a6ef 100644 --- a/src/analyzer-snapshot.ts +++ b/src/analyzer-snapshot.ts @@ -1,17 +1,26 @@ +import { checkSide } from "./delta.js"; import type { AnalyzerMetric, AnalyzerSnapshot, CheckResult, Issue } from "./types.js"; const DETAIL_DENYLIST = new Set(["assessment", "containerSvg", "graph", "reason", "skipped", "status", "toolRuns"]); export function buildAnalyzerSnapshots(checks: CheckResult[]): AnalyzerSnapshot[] { - return checks.map((check) => ({ - analyzerId: check.name, - status: checkStatus(check), - score: Number.isFinite(Number(check.score)) ? Number(check.score) : undefined, - findingCount: check.issues.length, - severityCounts: severityCounts(check.issues), - metrics: normalizedMetrics(check), - durationMs: check.duration, - })); + return checks.map((check) => { + const status = checkStatus(check); + // A check that did not run carries a placeholder 100 (core.ts), and a + // crashed runner a placeholder 0 (its status stays "failed"); neither is + // a measurement, so the snapshot has no score key at all (#107). + const ran = checkSide(check).score; + const score = ran !== undefined && Number.isFinite(Number(ran)) ? Number(ran) : undefined; + return { + analyzerId: check.name, + status, + ...(score === undefined ? {} : { score }), + findingCount: check.issues.length, + severityCounts: severityCounts(check.issues), + metrics: normalizedMetrics(check), + durationMs: check.duration, + }; + }); } function checkStatus(check: CheckResult): AnalyzerSnapshot["status"] { diff --git a/src/cli.ts b/src/cli.ts index f309e7c..9fc2c08 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { runInit } from "./commands/init.js"; import { validateCwd } from "./commands/shared.js"; import { loadConfig } from "./config.js"; import { scan } from "./core.js"; -import { computeDelta } from "./delta.js"; +import { computeDelta, formatCheckChangeBullets } from "./delta.js"; import { detectStack, detectWorkspace } from "./detect.js"; import { resolveFailUnder } from "./fail-under.js"; import { isPartialScan } from "./history.js"; @@ -187,14 +187,7 @@ function generateMarkdown(report: VibeReport, trend: TrendDelta | null, prevRepo md += "\n\n"; // Per-check changes - const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta); - if (changed.length > 0) { - for (const c of changed.slice(0, 8)) { - const a = c.delta > 0 ? "+" : ""; - md += `- ${c.delta > 0 ? "✅" : "⚠️"} ${c.name}: ${c.before} → ${c.after} (${a}${c.delta})\n`; - } - md += "\n"; - } + md += formatCheckChangeBullets(delta, 8); } else if (trend) { const arrow = trend.scoreDelta > 0 ? "📈" : trend.scoreDelta < 0 ? "📉" : "➡️"; md += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`; diff --git a/src/delta.test.ts b/src/delta.test.ts index f25e961..5d83bb4 100644 --- a/src/delta.test.ts +++ b/src/delta.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeDelta, formatDeltaMarkdown } from "./delta.js"; +import { checkRunState, computeDelta, formatCheckChangeBullets, formatCheckState, formatDeltaMarkdown, formatTransition } from "./delta.js"; import type { VibeReport } from "./types.js"; function makeReport(overrides: Partial = {}): VibeReport { @@ -114,3 +114,255 @@ describe("formatDeltaMarkdown", () => { expect(md).toContain("+10"); }); }); + +describe("not-run checks (#107)", () => { + const lintUnavailable = { + name: "lint", + status: "unavailable", + score: 100, + grade: "A" as const, + details: { skipped: true, unavailable: true, status: "unavailable", reason: "Dart SDK not installed" }, + issues: [], + duration: 1, + }; + const lintScored = { + name: "lint", + status: "failed", + score: 72, + grade: "C" as const, + details: { status: "failed" }, + issues: [], + duration: 1, + }; + + it("reports a status transition, not a 100 → 72 delta, when lint was unavailable before", () => { + const delta = computeDelta(makeReport({ checks: [lintUnavailable] }), makeReport({ checks: [lintScored] })); + const lint = delta.checks.find((c) => c.name === "lint")!; + expect(lint.delta).toBe(0); + expect(lint.transition).toEqual({ before: { state: "unavailable" }, after: { state: "ran", score: 72 } }); + expect(formatTransition(lint.transition!)).toBe("unavailable → 72"); + + const bullets = formatCheckChangeBullets(delta, 8); + expect(bullets).toContain("lint: unavailable → 72"); + expect(bullets).not.toContain("100 → 72"); + expect(bullets).not.toMatch(/\(-28\)/); + + const md = formatDeltaMarkdown(delta); + expect(md).toContain("## Status Changes"); + expect(md).toContain("| lint | unavailable | 72 |"); + expect(md).not.toContain("## Check Changes"); + expect(md).not.toContain("100"); + }); + + it("does not report removing a tool as an improvement", () => { + const scored64 = { ...lintScored, score: 64, grade: "D" as const }; + const delta = computeDelta(makeReport({ checks: [scored64] }), makeReport({ checks: [lintUnavailable] })); + const bullets = formatCheckChangeBullets(delta, 8); + expect(bullets).toContain("lint: 64 → unavailable"); + expect(bullets).not.toMatch(/\+\d/); + expect(bullets).not.toContain("✅"); + expect(formatDeltaMarkdown(delta)).not.toMatch(/\+36/); + }); + + it("falls back to details flags for reports without a status field", () => { + const legacyComingSoon = { name: "ai-review", score: 100, grade: "A" as const, details: { comingSoon: true }, issues: [], duration: 1 }; + const legacySkipped = { name: "lint", score: 100, grade: "A" as const, details: { skipped: true }, issues: [], duration: 1 }; + const legacyRunnerError = { + name: "types", + score: 0, + grade: "F" as const, + details: { skipped: true, reason: "runner error: boom" }, + issues: [], + duration: 1, + }; + expect(checkRunState(legacyComingSoon)).toBe("unavailable"); + expect(checkRunState(legacySkipped)).toBe("skipped"); + expect(checkRunState(legacyRunnerError)).toBe("runner-error"); + expect(checkRunState(undefined)).toBe("absent"); + + const delta = computeDelta(makeReport({ checks: [legacySkipped] }), makeReport({ checks: [lintScored] })); + expect(delta.checks[0]).toMatchObject({ delta: 0, transition: { before: { state: "skipped" }, after: { state: "ran", score: 72 } } }); + }); + + it("reports nothing when a check stays skipped", () => { + const skipped = { ...lintUnavailable, status: "skipped", details: { skipped: true, status: "skipped" } }; + const delta = computeDelta(makeReport({ checks: [skipped] }), makeReport({ checks: [skipped] })); + expect(delta.checks).toHaveLength(0); + }); + + it("keeps numeric deltas when both sides ran", () => { + const delta = computeDelta(makeReport({ checks: [{ ...lintScored, score: 60 }] }), makeReport({ checks: [lintScored] })); + expect(delta.checks[0]).toMatchObject({ delta: 12 }); + expect(delta.checks[0].transition).toBeUndefined(); + expect(formatCheckChangeBullets(delta, 8)).toContain("✅ lint: 60 → 72 (+12)"); + }); + + it("reports a check that disappeared as a transition and does not count its issues as fixed", () => { + const lintWithIssues = { + ...lintScored, + issues: [ + { severity: "error" as const, message: "unused var", file: "src/a.ts", rule: "no-unused" }, + { severity: "warning" as const, message: "prefer const", file: "src/b.ts", rule: "prefer-const" }, + ], + }; + const delta = computeDelta(makeReport({ checks: [lintWithIssues] }), makeReport({ checks: [] })); + const lint = delta.checks.find((c) => c.name === "lint")!; + expect(lint).toMatchObject({ before: 72, after: null, delta: 0 }); + expect(lint.transition).toEqual({ before: { state: "ran", score: 72 }, after: { state: "absent" } }); + expect(lint.fixed).toHaveLength(0); + expect(delta.fixed).toHaveLength(0); + expect(formatCheckChangeBullets(delta, 8)).toContain("lint: 72 → not present"); + expect(formatDeltaMarkdown(delta)).not.toContain("## Fixed"); + }); + + it("carries null, not the placeholder 100, for a side that did not run", () => { + const delta = computeDelta(makeReport({ checks: [lintUnavailable] }), makeReport({ checks: [lintScored] })); + expect(delta.checks[0]).toMatchObject({ before: null, after: 72 }); + const back = computeDelta(makeReport({ checks: [lintScored] }), makeReport({ checks: [lintUnavailable] })); + expect(back.checks[0]).toMatchObject({ before: 72, after: null }); + expect(JSON.stringify(back)).not.toContain('"after":100'); + }); +}); + +describe("crashed runner (#107)", () => { + // core.ts's stub for a runner that threw: status failed, a placeholder 0/F. + const lintCrashed = { + name: "lint", + status: "failed", + score: 0, + grade: "F" as const, + details: { skipped: true, status: "failed", reason: "runner error: eslint exited 2" }, + issues: [], + duration: 0, + }; + const lintScored = { + name: "lint", + status: "failed", + score: 72, + grade: "C" as const, + details: { status: "failed" }, + issues: [], + duration: 1, + }; + + it("reports 72 → runner error as a transition, with no numeric delta", () => { + const delta = computeDelta(makeReport({ checks: [lintScored] }), makeReport({ checks: [lintCrashed] })); + const lint = delta.checks.find((c) => c.name === "lint")!; + expect(lint).toMatchObject({ before: 72, after: null, delta: 0 }); + expect(lint.transition).toEqual({ before: { state: "ran", score: 72 }, after: { state: "runner-error" } }); + expect(formatTransition(lint.transition!)).toBe("72 → failed (runner error)"); + + const bullets = formatCheckChangeBullets(delta, 8); + expect(bullets).toContain("- lint: 72 → failed (runner error)"); + expect(bullets).not.toContain("-72"); + expect(bullets).not.toContain("72 → 0"); + + const md = formatDeltaMarkdown(delta); + expect(md).toContain("| lint | 72 | failed (runner error) |"); + expect(md).not.toContain("## Check Changes"); + expect(md).not.toContain("-72"); + }); + + it("reports runner error → 72 as a transition, not +72", () => { + const delta = computeDelta(makeReport({ checks: [lintCrashed] }), makeReport({ checks: [lintScored] })); + const lint = delta.checks.find((c) => c.name === "lint")!; + expect(lint).toMatchObject({ before: null, after: 72, delta: 0 }); + expect(formatTransition(lint.transition!)).toBe("failed (runner error) → 72"); + expect(formatCheckChangeBullets(delta, 8)).not.toContain("+72"); + expect(formatDeltaMarkdown(delta)).not.toContain("+72"); + + // Crashed in both scans: nothing changed, nothing to report. + expect(computeDelta(makeReport({ checks: [lintCrashed] }), makeReport({ checks: [lintCrashed] })).checks).toHaveLength(0); + }); + + it("detects a crash from details alone (history snapshots, older reports)", () => { + const { status: _status, ...noTopStatus } = lintCrashed; + expect(checkRunState(noTopStatus)).toBe("runner-error"); + expect(checkRunState({ ...noTopStatus, details: { skipped: true, reason: "runner error: boom" } })).toBe("runner-error"); + // An explicit skipped/unavailable status still wins over the reason. + expect(checkRunState({ ...lintCrashed, status: "unavailable" })).toBe("unavailable"); + // `status` is an open vocabulary (schema 0.6.0): an unknown value counts as ran. + expect(checkRunState({ ...lintScored, status: "timeout" })).toBe("ran"); + expect(checkRunState({ ...lintScored, status: "timeout", details: { status: "timeout" } })).toBe("ran"); + }); +}); + +describe("issues of a check that did not run on one side are not diffed (#107)", () => { + const threeIssues = [ + { severity: "error" as const, message: "unused var", file: "src/a.ts", rule: "no-unused" }, + { severity: "warning" as const, message: "prefer const", file: "src/b.ts", rule: "prefer-const" }, + { severity: "warning" as const, message: "no any", file: "src/c.ts", rule: "no-any" }, + ]; + const lintRan = { + name: "lint", + status: "failed", + score: 72, + grade: "C" as const, + details: { status: "failed" }, + issues: threeIssues, + duration: 1, + }; + const notRun = { + crashed: { + name: "lint", + status: "failed", + score: 0, + grade: "F" as const, + details: { skipped: true, status: "failed", reason: "runner error: boom" }, + issues: [], + duration: 0, + }, + unavailable: { + name: "lint", + status: "unavailable", + score: 100, + grade: "A" as const, + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }, + dropped: undefined, + }; + const cases = Object.entries(notRun).flatMap(([kind, other]) => [ + { title: `ran (3 issues) → ${kind}`, before: [lintRan], after: other ? [other] : [] }, + { title: `${kind} → ran (3 issues)`, before: other ? [other] : [], after: [lintRan] }, + ]); + + it.each(cases)("$title: 0 fixed, 0 new; only the status transition", ({ before, after }) => { + const delta = computeDelta(makeReport({ checks: before }), makeReport({ checks: after })); + expect(delta.fixed).toHaveLength(0); + expect(delta.introduced).toHaveLength(0); + const lint = delta.checks.find((c) => c.name === "lint")!; + expect(lint.transition).toBeDefined(); + expect(lint.fixed).toHaveLength(0); + expect(lint.introduced).toHaveLength(0); + + const md = formatDeltaMarkdown(delta); + expect(md).toContain("| 0 fixed, 0 new |"); + expect(md).not.toContain("## Fixed"); + expect(md).not.toContain("## New Issues"); + expect(md).not.toMatch(/\b3 (fixed|new)\b/); + expect(formatCheckChangeBullets(delta, 8)).not.toMatch(/fixed|new/); + }); + + it("still diffs issues when the check ran on both sides", () => { + const delta = computeDelta(makeReport({ checks: [lintRan] }), makeReport({ checks: [{ ...lintRan, issues: threeIssues.slice(1) }] })); + expect(delta.fixed).toHaveLength(1); + expect(delta.introduced).toHaveLength(0); + }); +}); + +describe("formatCheckState (#107)", () => { + it("shows the full crash reason for a single check, truncated to 80 chars", () => { + expect(formatCheckState({ state: "runner-error" }, "runner error: eslint exited 2")).toBe("failed (runner error: eslint exited 2)"); + const long = `runner error: ${"x".repeat(200)}`; + const out = formatCheckState({ state: "runner-error" }, long); + expect(out).toBe(`failed (${long.slice(0, 79)}…)`); + }); + + it("falls back to the short token without a reason, and for other states", () => { + expect(formatCheckState({ state: "runner-error" })).toBe("failed (runner error)"); + expect(formatCheckState({ state: "unavailable" }, "Dart SDK not installed")).toBe("unavailable"); + expect(formatCheckState({ state: "ran", score: 72 })).toBe("72"); + }); +}); diff --git a/src/delta.ts b/src/delta.ts index 6f66b90..5b98c59 100644 --- a/src/delta.ts +++ b/src/delta.ts @@ -5,7 +5,100 @@ */ import { comparableFingerprints } from "./issue-fingerprint.js"; -import type { Issue, VibeReport } from "./types.js"; +import type { CheckResult, Issue, VibeReport } from "./types.js"; + +/** + * Whether a check produced a real score. A check that did not run carries a + * placeholder `score: 100` (core.ts), which must never be subtracted from a + * real score: "lint: 100 → 72" would read as a regression, and the reverse + * (a tool uninstalled) as an improvement (#107). + * + * `runner-error`: the check ran and its runner crashed. It counts as having + * run, with status `failed`, but its `score: 0` / grade F is a placeholder + * too, so it is compared as a state, never as a number. + */ +export type CheckRunState = "ran" | "runner-error" | "skipped" | "unavailable" | "absent"; + +/** One side of a check comparison. `score` is set only when the check ran. */ +export interface CheckSide { + state: CheckRunState; + score?: number; +} + +/** A check whose run state changed between two scans, e.g. lint: unavailable → 72. */ +export interface StatusTransition { + before: CheckSide; + after: CheckSide; +} + +/** + * Run state of a check. Reads `status` (the CLI writes it; it survives schema + * 0.5.0 via `.passthrough()`), else `details.status` (what history snapshots + * keep). Reports without either fall back to the details flags, in the order + * core.ts uses (`unavailable`/`comingSoon` before `skipped`). + * + * A "runner error:" reason is a crashed runner (core.ts reports it as + * `failed`); an explicit `skipped`/`unavailable` status still wins over it. + * `status` is an open vocabulary (schema 0.6.0): any other value counts as ran. + */ +export function checkRunState(check: CheckResult | undefined): CheckRunState { + if (!check) return "absent"; + const details = (check.details ?? {}) as Record; + const topStatus: unknown = (check as { status?: unknown }).status; + const status: unknown = typeof topStatus === "string" && topStatus ? topStatus : details.status; + if (status === "skipped") return "skipped"; + if (status === "unavailable") return "unavailable"; + const reason = typeof details.reason === "string" ? details.reason : ""; + if (reason.startsWith("runner error:")) return "runner-error"; + if (typeof status === "string" && status) return "ran"; + if (details.unavailable || details.comingSoon) return "unavailable"; + if (details.skipped) return "skipped"; + return "ran"; +} + +export function checkSide(check: CheckResult | undefined): CheckSide { + const state = checkRunState(check); + return state === "ran" && check ? { state, score: check.score } : { state }; +} + +/** + * Compare two sides of one check: a numeric delta when both ran, otherwise a + * status transition (or nothing, when the not-run state is unchanged). + */ +export function compareCheckSides(before: CheckSide, after: CheckSide): { delta: number; transition?: StatusTransition } { + if (before.state === "ran" && after.state === "ran") return { delta: (after.score ?? 0) - (before.score ?? 0) }; + if (before.state === after.state) return { delta: 0 }; + return { delta: 0, transition: { before, after } }; +} + +/** + * Whether a check's issues can be diffed between two scans: only when it ran + * on both sides. A crashed, skipped, unavailable or absent side has no issue + * list to compare, so diffing against it would report every issue on the + * other side as fixed (or new) when nothing changed in the code (#107). + */ +export function issuesComparable(before: CheckSide, after: CheckSide): boolean { + return before.state === "ran" && after.state === "ran"; +} + +/** "72", "failed (runner error)", "unavailable", "skipped" or "not present". */ +export function formatCheckSide(side: CheckSide): string { + if (side.state === "ran") return String(side.score); + if (side.state === "runner-error") return "failed (runner error)"; + return side.state === "absent" ? "not present" : side.state; +} + +/** + * One check's state shown on its own (e.g. a Trends-page card), where the + * crash reason is the useful detail: "failed (runner error: eslint exited 2)". + * Transition and delta lines keep the short `formatCheckSide` token: the + * reason varies between runs and can carry local paths. + */ +export function formatCheckState(side: CheckSide, reason?: string): string { + if (side.state !== "runner-error" || !reason?.startsWith("runner error:")) return formatCheckSide(side); + const text = reason.length > 80 ? `${reason.slice(0, 79)}…` : reason; + return `failed (${text})`; +} export interface DeltaIssue { check: string; @@ -19,10 +112,17 @@ export interface DeltaIssue { export interface CheckDelta { name: string; label: string; - before: number; - after: number; + /** Score before; null when the check did not run, its runner crashed, or it was absent (see `transition`). */ + before: number | null; + /** Score after; null when the check did not run, its runner crashed, or it was absent (see `transition`). */ + after: number | null; + /** Score change; 0 whenever either side did not run or crashed (see `transition`). */ delta: number; + /** Set instead of a numeric delta when the check's run state changed. */ + transition?: StatusTransition; + /** Issues no longer reported. Always empty when either side did not run, crashed or is absent. */ fixed: DeltaIssue[]; + /** Issues newly reported. Always empty when either side did not run, crashed or is absent. */ introduced: DeltaIssue[]; } @@ -35,6 +135,40 @@ export interface ScanDelta { introduced: DeltaIssue[]; } +type IssueMultiset = Map; + +/** Count a check's issues per fingerprint. `issueKey` comes from + * `comparableFingerprints`, so both reports are keyed by the same scheme. */ +function issueMultiset(name: string, check: CheckResult | undefined, issueKey: (check: string, iss: Issue) => string): IssueMultiset { + const out: IssueMultiset = new Map(); + for (const iss of check?.issues ?? []) { + const key = issueKey(name, iss); + const entry = out.get(key); + if (entry) entry.count++; + else out.set(key, { count: 1, issue: iss }); + } + return out; +} + +/** Issues in `from` beyond their count in `minus`, as DeltaIssues. */ +function multisetDifference(name: string, from: IssueMultiset, minus: IssueMultiset): DeltaIssue[] { + const out: DeltaIssue[] = []; + for (const [key, entry] of from) { + const diff = entry.count - (minus.get(key)?.count ?? 0); + for (let i = 0; i < diff; i++) { + out.push({ + check: name, + severity: entry.issue.severity, + message: entry.issue.message, + file: typeof entry.issue.file === "string" ? entry.issue.file : undefined, + line: entry.issue.line, + rule: entry.issue.rule, + }); + } + } + return out; +} + /** Compute a structured delta between two scan reports. */ export function computeDelta(before: VibeReport, after: VibeReport): ScanDelta { const beforeIssueCount = before.checks.reduce((s, c) => s + c.issues.length, 0); @@ -47,76 +181,41 @@ export function computeDelta(before: VibeReport, after: VibeReport): ScanDelta { // recomputed on both sides across the v1/v2 boundary (#97). const issueKey = comparableFingerprints(before, after); - for (const afterCheck of after.checks) { - const beforeCheck = before.checks.find((c) => c.name === afterCheck.name); - const beforeScore = beforeCheck?.score ?? 0; - - // Build multiset of issue keys for before and after - const beforeKeys = new Map(); - const afterKeys = new Map(); + // Union of check names: a check present only in `before` (tool removed, + // check dropped or renamed) still gets a "72 → not present" transition. + // Its issues are not counted as fixed: the check stopped looking. + const names = [...after.checks.map((c) => c.name)]; + for (const c of before.checks) if (!names.includes(c.name)) names.push(c.name); - if (beforeCheck) { - for (const iss of beforeCheck.issues) { - const key = issueKey(afterCheck.name, iss); - const entry = beforeKeys.get(key); - if (entry) entry.count++; - else beforeKeys.set(key, { count: 1, issue: iss }); - } - } - for (const iss of afterCheck.issues) { - const key = issueKey(afterCheck.name, iss); - const entry = afterKeys.get(key); - if (entry) entry.count++; - else afterKeys.set(key, { count: 1, issue: iss }); - } + for (const name of names) { + const beforeCheck = before.checks.find((c) => c.name === name); + const afterCheck = after.checks.find((c) => c.name === name); + const beforeSide = checkSide(beforeCheck); + const afterSide = checkSide(afterCheck); + const { delta: scoreChange, transition } = compareCheckSides(beforeSide, afterSide); - const fixed: DeltaIssue[] = []; - const introduced: DeltaIssue[] = []; - - // Fixed: in before but not in after (or count decreased) - for (const [key, bEntry] of beforeKeys) { - const aEntry = afterKeys.get(key); - const aCount = aEntry?.count ?? 0; - const diff = bEntry.count - aCount; - for (let i = 0; i < diff; i++) { - const di: DeltaIssue = { - check: afterCheck.name, - severity: bEntry.issue.severity, - message: bEntry.issue.message, - file: typeof bEntry.issue.file === "string" ? bEntry.issue.file : undefined, - line: bEntry.issue.line, - rule: bEntry.issue.rule, - }; - fixed.push(di); - allFixed.push(di); - } - } - - // Introduced: in after but not in before (or count increased) - for (const [key, aEntry] of afterKeys) { - const bEntry = beforeKeys.get(key); - const bCount = bEntry?.count ?? 0; - const diff = aEntry.count - bCount; - for (let i = 0; i < diff; i++) { - const di: DeltaIssue = { - check: afterCheck.name, - severity: aEntry.issue.severity, - message: aEntry.issue.message, - file: typeof aEntry.issue.file === "string" ? aEntry.issue.file : undefined, - line: aEntry.issue.line, - rule: aEntry.issue.rule, - }; - introduced.push(di); - allIntroduced.push(di); - } + // Issues are diffed only when the check ran on both sides; otherwise + // the change is the status transition, not N fixed / N new. + let fixed: DeltaIssue[] = []; + let introduced: DeltaIssue[] = []; + if (issuesComparable(beforeSide, afterSide)) { + const beforeKeys = issueMultiset(name, beforeCheck, issueKey); + const afterKeys = issueMultiset(name, afterCheck, issueKey); + // Fixed: in before but not in after (or count decreased) + fixed = multisetDifference(name, beforeKeys, afterKeys); + // Introduced: in after but not in before (or count increased) + introduced = multisetDifference(name, afterKeys, beforeKeys); } + allFixed.push(...fixed); + allIntroduced.push(...introduced); checks.push({ - name: afterCheck.name, - label: afterCheck.name, - before: beforeScore, - after: afterCheck.score, - delta: afterCheck.score - beforeScore, + name, + label: name, + before: beforeSide.score ?? null, + after: afterSide.score ?? null, + delta: scoreChange, + ...(transition ? { transition } : {}), fixed, introduced, }); @@ -126,12 +225,49 @@ export function computeDelta(before: VibeReport, after: VibeReport): ScanDelta { before: { score: before.score, grade: before.grade, timestamp: before.timestamp, issueCount: beforeIssueCount }, after: { score: after.score, grade: after.grade, timestamp: after.timestamp, issueCount: afterIssueCount }, scoreDelta: after.score - before.score, - checks: checks.filter((c) => c.delta !== 0 || c.fixed.length > 0 || c.introduced.length > 0), + checks: checks.filter((c) => c.delta !== 0 || c.transition || c.fixed.length > 0 || c.introduced.length > 0), fixed: allFixed, introduced: allIntroduced, }; } +/** Checks whose score changed, both sides having run; biggest gain first. */ +export function scoreChanges(delta: ScanDelta): CheckDelta[] { + return delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta); +} + +/** Checks whose run state changed (ran ↔ runner-error/skipped/unavailable/absent). */ +export function statusTransitions(delta: ScanDelta): (CheckDelta & { transition: StatusTransition })[] { + return delta.checks.filter((c): c is CheckDelta & { transition: StatusTransition } => c.transition !== undefined); +} + +export function formatTransition(t: StatusTransition): string { + return `${formatCheckSide(t.before)} → ${formatCheckSide(t.after)}`; +} + +/** + * Markdown bullet list of per-check changes, as the PR comment and the CLI + * summary print it: score changes, then status transitions separately. + */ +export function formatCheckChangeBullets(delta: ScanDelta, limit: number): string { + let md = ""; + const changed = scoreChanges(delta); + if (changed.length > 0) { + for (const c of changed.slice(0, limit)) { + const a = c.delta > 0 ? "+" : ""; + md += `- ${c.delta > 0 ? "✅" : "⚠️"} ${c.name}: ${c.before} → ${c.after} (${a}${c.delta})\n`; + } + md += "\n"; + } + const transitions = statusTransitions(delta); + if (transitions.length > 0) { + md += "**Status changes** (not scored):\n"; + for (const c of transitions.slice(0, limit)) md += `- ${c.name}: ${formatTransition(c.transition)}\n`; + md += "\n"; + } + return md; +} + /** Format a delta as a markdown report. */ export function formatDeltaMarkdown(delta: ScanDelta): string { const arrow = delta.scoreDelta > 0 ? "+" : ""; @@ -143,17 +279,28 @@ export function formatDeltaMarkdown(delta: ScanDelta): string { md += `| **Issues** | ${delta.before.issueCount} | ${delta.after.issueCount} | ${delta.fixed.length} fixed, ${delta.introduced.length} new |\n\n`; // Per-check changes - const changed = delta.checks.filter((c) => c.delta !== 0); + const changed = scoreChanges(delta); if (changed.length > 0) { md += `## Check Changes\n\n`; md += `| Check | Before | After | Delta |\n|---|---|---|---|\n`; - for (const c of changed.sort((a, b) => b.delta - a.delta)) { + for (const c of changed) { const a = c.delta > 0 ? "+" : ""; md += `| ${c.name} | ${c.before} | ${c.after} | ${a}${c.delta} |\n`; } md += "\n"; } + // Checks that started or stopped running: a placeholder score is not a score. + const transitions = statusTransitions(delta); + if (transitions.length > 0) { + md += `## Status Changes\n\n`; + md += `| Check | Before | After |\n|---|---|---|\n`; + for (const c of transitions) { + md += `| ${c.name} | ${formatCheckSide(c.transition.before)} | ${formatCheckSide(c.transition.after)} |\n`; + } + md += "\n"; + } + // Fixed issues if (delta.fixed.length > 0) { md += `## Fixed (${delta.fixed.length})\n\n`; diff --git a/src/history.test.ts b/src/history.test.ts index bd0f2fc..34421c4 100644 --- a/src/history.test.ts +++ b/src/history.test.ts @@ -180,6 +180,32 @@ describe("loadHistory", () => { expect(entries[0].checkScores.has("testing")).toBe(false); }); + it("keeps not-run checks out of checkScores and records their run state (#107)", () => { + writeFileSync( + join(tmp, "2026-05-15T11-00-00.json"), + JSON.stringify({ + timestamp: "2026-05-15T11:00:00.000Z", + score: 80, + checks: [ + { name: "lint", score: 100, details: { skipped: true, unavailable: true, status: "unavailable" } }, + { name: "ai-review", score: 100, details: { comingSoon: true } }, + { name: "deps", score: 100, details: { skipped: true } }, + { name: "types", score: 0, details: { skipped: true, reason: "runner error: boom" } }, + { name: "structure", score: 90, details: {} }, + ], + }), + ); + const [entry] = loadHistory(tmp); + expect([...entry.checkScores]).toEqual([["structure", 90]]); + expect(Object.fromEntries(entry.checkStates)).toEqual({ + lint: "unavailable", + "ai-review": "unavailable", + deps: "skipped", + types: "runner-error", + structure: "ran", + }); + }); + it("limits to last 30 entries", () => { for (let i = 0; i < 40; i++) { const day = String(i + 1).padStart(2, "0"); diff --git a/src/history.ts b/src/history.ts index 916f68a..6ed49b0 100644 --- a/src/history.ts +++ b/src/history.ts @@ -2,13 +2,19 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { type CheckRunState, checkRunState } from "./delta.js"; import type { IssueSnapshot } from "./issue-fingerprint.js"; import type { AnalyzerSnapshot, VibeReport } from "./types.js"; export interface HistoryEntry { timestamp: string; score: number; + /** Scores of checks that ran. A not-run check's placeholder 100, and a crashed runner's 0, are left out (#107). */ checkScores: Map; + /** Run state of every check in the snapshot (from `status` / `details.status`, the "runner error:" reason, else the details flags). */ + checkStates: Map>; + /** The "runner error: …" reason of each check whose runner crashed. */ + checkReasons: Map; issues: IssueSnapshot[]; analyzerSnapshots: AnalyzerSnapshot[]; } @@ -36,20 +42,20 @@ export function loadHistory(historyDir: string): HistoryEntry[] { const raw: VibeReport = JSON.parse(readFileSync(join(historyDir, file), "utf-8")); if (!isComparableSnapshot(raw)) continue; const checkScores = new Map(); + const checkStates: HistoryEntry["checkStates"] = new Map(); + const checkReasons = new Map(); const issues: IssueSnapshot[] = []; const analyzerSnapshots = normalizeAnalyzerSnapshots(raw.meta?.analyzerSnapshots); - for (const c of raw.checks) { - if (c.score !== null && c.score !== undefined && Number.isFinite(Number(c.score))) { - checkScores.set(c.name, Number(c.score)); - } - if (Array.isArray((c as { issues?: unknown }).issues)) { - for (const issue of (c as { issues: unknown[] }).issues) { - const snapshot = normalizeHistoryIssue(c.name, issue); - if (snapshot) issues.push(snapshot); - } - } - } - entries.push({ timestamp: raw.timestamp, score: Number(raw.score), checkScores, issues, analyzerSnapshots }); + for (const c of raw.checks) readHistoryCheck(c, { checkScores, checkStates, checkReasons, issues }); + entries.push({ + timestamp: raw.timestamp, + score: Number(raw.score), + checkScores, + checkStates, + checkReasons, + issues, + analyzerSnapshots, + }); } catch { // skip corrupt files } @@ -58,6 +64,25 @@ export function loadHistory(historyDir: string): HistoryEntry[] { return entries.slice(-30); } +function readHistoryCheck( + c: VibeReport["checks"][number], + { checkScores, checkStates, checkReasons, issues }: Pick, +): void { + const state = checkRunState(c); + if (state !== "absent") checkStates.set(c.name, state); + const reason = (c.details as Record | undefined)?.reason; + if (state === "runner-error" && typeof reason === "string") checkReasons.set(c.name, reason); + if (state === "ran" && c.score !== null && c.score !== undefined && Number.isFinite(Number(c.score))) { + checkScores.set(c.name, Number(c.score)); + } + if (Array.isArray((c as { issues?: unknown }).issues)) { + for (const issue of (c as { issues: unknown[] }).issues) { + const snapshot = normalizeHistoryIssue(c.name, issue); + if (snapshot) issues.push(snapshot); + } + } +} + /** A scored, full-scan snapshot: one the series can compare. */ function isComparableSnapshot(raw: VibeReport): boolean { if (raw.score === null || raw.score === undefined || !Number.isFinite(Number(raw.score)) || !raw.checks) return false; diff --git a/src/pr-comment.test.ts b/src/pr-comment.test.ts index 54290c4..937514e 100644 --- a/src/pr-comment.test.ts +++ b/src/pr-comment.test.ts @@ -25,3 +25,111 @@ describe("pr-comment module", () => { expect(result).toBe(false); }); }); + +describe("PR comment body (#107)", () => { + const base = { + version: "0.56.0", + timestamp: "2026-10-05T00:00:00Z", + score: 80, + grade: "B" as const, + meta: { cwd: "/tmp", node: "v22", duration: 100, stack: {} as any, repoUrl: null, branch: "main" }, + }; + const unavailable = { + name: "lint", + status: "unavailable", + score: 100, + grade: "A" as const, + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }; + const scored = { name: "lint", status: "failed", score: 72, grade: "C" as const, details: { status: "failed" }, issues: [], duration: 1 }; + + it("lists lint unavailable → 72 as a status change, not a 100 → 72 regression", async () => { + const { buildCommentBody } = await import("./pr-comment.js"); + const body = buildCommentBody({ ...base, checks: [scored] }, null, { ...base, checks: [unavailable] }); + expect(body).toContain("lint: unavailable → 72"); + expect(body).not.toContain("100 → 72"); + expect(body).not.toContain("(-28)"); + }); + + it("does not show removing the tool as a +N improvement", async () => { + const { buildCommentBody } = await import("./pr-comment.js"); + const body = buildCommentBody({ ...base, checks: [unavailable] }, null, { ...base, checks: [{ ...scored, score: 64 }] }); + expect(body).toContain("lint: 64 → unavailable"); + expect(body).not.toContain("+36"); + expect(body).not.toContain("64 → 100"); + }); + + const crashed = { + name: "lint", + status: "failed", + score: 0, + grade: "F" as const, + details: { skipped: true, status: "failed", reason: "runner error: boom" }, + issues: [], + duration: 0, + }; + + it("shows 72 → runner error as a status change, not a -72 regression", async () => { + const { buildCommentBody } = await import("./pr-comment.js"); + const body = buildCommentBody({ ...base, checks: [crashed] }, null, { ...base, checks: [scored] }); + expect(body).toContain("lint: 72 → failed (runner error)"); + expect(body).not.toContain("72 → 0"); + expect(body).not.toContain("-72"); + }); + + it("shows runner error → 72 as a status change, not a +72 improvement", async () => { + const { buildCommentBody } = await import("./pr-comment.js"); + const body = buildCommentBody({ ...base, checks: [scored] }, null, { ...base, checks: [crashed] }); + expect(body).toContain("lint: failed (runner error) → 72"); + expect(body).not.toContain("0 → 72"); + expect(body).not.toContain("+72"); + }); +}); + +describe("PR comment does not count issues of a check that did not run on one side (#107)", () => { + const base = { + version: "0.56.0", + timestamp: "2026-10-05T00:00:00Z", + score: 80, + grade: "B" as const, + meta: { cwd: "/tmp", node: "v22", duration: 100, stack: {} as any, repoUrl: null, branch: "main" }, + }; + const issues = ["a", "b", "c"].map((f) => ({ severity: "warning" as const, message: `issue in ${f}`, file: `src/${f}.ts`, rule: "r" })); + const ran = { name: "lint", status: "failed", score: 72, grade: "C" as const, details: { status: "failed" }, issues, duration: 1 }; + const notRun = { + crashed: { + name: "lint", + status: "failed", + score: 0, + grade: "F" as const, + details: { skipped: true, status: "failed", reason: "runner error: boom" }, + issues: [], + duration: 0, + }, + unavailable: { + name: "lint", + status: "unavailable", + score: 100, + grade: "A" as const, + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }, + dropped: undefined, + }; + const cases = Object.entries(notRun).flatMap(([kind, other]) => [ + { title: `ran (3 issues) → ${kind}`, prev: [ran], curr: other ? [other] : [] }, + { title: `${kind} → ran (3 issues)`, prev: other ? [other] : [], curr: [ran] }, + ]); + + it.each(cases)("$title: no fixed / new count in the headline", async ({ prev, curr }) => { + const { buildCommentBody } = await import("./pr-comment.js"); + const body = buildCommentBody({ ...base, checks: curr }, null, { ...base, checks: prev }); + const headline = body.split("\n").find((l) => l.includes("vs previous"))!; + expect(headline).toBeDefined(); + expect(headline).not.toMatch(/fixed|new/); + expect(body).toContain("**Status changes** (not scored):"); + }); +}); diff --git a/src/pr-comment.ts b/src/pr-comment.ts index 95e4943..f22daf0 100644 --- a/src/pr-comment.ts +++ b/src/pr-comment.ts @@ -2,7 +2,7 @@ import { execSync } from "node:child_process"; import { type CiContext, detectCiContext } from "./ci-context.js"; -import { computeDelta } from "./delta.js"; +import { computeDelta, formatCheckChangeBullets } from "./delta.js"; import type { TrendDelta } from "./trend.js"; import type { VibeReport } from "./types.js"; @@ -73,7 +73,7 @@ export function detectPR(cwd: string, ctx: CiContext = detectCiContext(cwd)): PR return null; } -function buildCommentBody(report: VibeReport, trend: TrendDelta | null, prevReport?: VibeReport): string { +export function buildCommentBody(report: VibeReport, trend: TrendDelta | null, prevReport?: VibeReport): string { const grade = report.grade; const score = report.score; const gradeEmoji = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : "🔴"; @@ -88,14 +88,7 @@ function buildCommentBody(report: VibeReport, trend: TrendDelta | null, prevRepo if (delta.introduced.length > 0) body += ` · ${delta.introduced.length} new`; body += "\n\n"; - const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta); - if (changed.length > 0) { - for (const c of changed.slice(0, 6)) { - const a = c.delta > 0 ? "+" : ""; - body += `- ${c.delta > 0 ? "✅" : "⚠️"} ${c.name}: ${c.before} → ${c.after} (${a}${c.delta})\n`; - } - body += "\n"; - } + body += formatCheckChangeBullets(delta, 6); } else if (trend) { const arrow = trend.scoreDelta > 0 ? "📈" : trend.scoreDelta < 0 ? "📉" : "➡️"; body += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`; diff --git a/src/report/pages.test.ts b/src/report/pages.test.ts index c9fa177..32f8f7a 100644 --- a/src/report/pages.test.ts +++ b/src/report/pages.test.ts @@ -1,7 +1,8 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { buildReportHistorySnapshot } from "../report-contract.js"; import type { CheckResult, VibeReport } from "../types.js"; // We test categoryPage indirectly through generatePages @@ -226,6 +227,233 @@ describe("report generation", () => { }); }); +describe("actions page delta (#107)", () => { + const unavailable: CheckResult = { + name: "lint", + score: 100, + grade: "A", + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }; + const scored: CheckResult = { name: "lint", score: 72, grade: "C", details: { status: "failed" }, issues: [], duration: 1 }; + (unavailable as CheckResult & { status: string }).status = "unavailable"; + (scored as CheckResult & { status: string }).status = "failed"; + + it("shows the status transition and no 100 → 72 score change", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const html = generatePages(makeReport(dir, [scored]), undefined, makeReport(dir, [unavailable])).get("actions.html")!; + expect(html).toContain("Status changes:"); + expect(html).toContain("lint: unavailable → 72"); + expect(html).not.toContain("lint -28"); + }); + + it("does not show removing the tool as an improvement", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const html = generatePages(makeReport(dir, [unavailable]), undefined, makeReport(dir, [{ ...scored, score: 64 }])).get("actions.html")!; + expect(html).toContain("lint: 64 → unavailable"); + expect(html).not.toContain("lint +36"); + }); +}); + +describe("trends page with not-run checks (#107)", () => { + function writeHistory(dir: string, entries: { timestamp: string; checks: CheckResult[] }[]): string { + const historyDir = join(dir, "history"); + mkdirSync(historyDir, { recursive: true }); + for (const { timestamp, checks } of entries) { + const report = { ...makeReport(dir, checks), timestamp }; + writeFileSync(join(historyDir, `${timestamp}.json`), JSON.stringify(buildReportHistorySnapshot(report))); + } + return historyDir; + } + const lint = (score: number): CheckResult => ({ + name: "lint", + score, + grade: "C", + details: { status: "failed" }, + issues: [], + duration: 1, + }); + const lintUnavailable: CheckResult = { + name: "lint", + score: 100, + grade: "A", + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }; + const structure = (score: number): CheckResult => ({ name: "structure", score, grade: "B", details: {}, issues: [], duration: 1 }); + const trendRows = (html: string) => + [...html.matchAll(/
(.*?)<\/div>/g)].map((m) => + m[1] + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); + + it("shows uninstalling a tool as a transition, not +36", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const historyDir = writeHistory(dir, [ + { timestamp: "2026-10-01T00:00:00.000Z", checks: [lint(64), structure(80)] }, + { timestamp: "2026-10-02T00:00:00.000Z", checks: [lint(70), structure(84)] }, + { timestamp: "2026-10-03T00:00:00.000Z", checks: [lintUnavailable, structure(86)] }, + ]); + const r2 = makeReport(dir, [lintUnavailable, structure(86)]); + const html = generatePages(r2, historyDir).get("trends.html")!; + const rows = trendRows(html); + expect(rows).toContain("structure 80 → 86 +6"); + expect(rows).toContain("lint 64 → unavailable status"); + expect(html).not.toContain("+36"); + expect(rows.join("\n")).not.toMatch(/lint.*100/); + // The lint card plots only the scans where lint ran and labels its current state. + expect(html).toMatch(/lint<\/span>unavailable<\/span>/); + }); + + it("shows installing a tool, and a dropped check, as transitions (legacy details flags)", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const legacySkipped: CheckResult = { name: "lint", score: 100, grade: "A", details: { skipped: true }, issues: [], duration: 1 }; + const historyDir = writeHistory(dir, [ + { timestamp: "2026-10-01T00:00:00.000Z", checks: [legacySkipped, structure(80)] }, + { timestamp: "2026-10-02T00:00:00.000Z", checks: [lint(72)] }, + { timestamp: "2026-10-03T00:00:00.000Z", checks: [lint(75)] }, + ]); + const html = generatePages(makeReport(dir, [lint(75)]), historyDir).get("trends.html")!; + const rows = trendRows(html); + expect(rows).toContain("lint skipped → 75 status"); + expect(rows).toContain("structure 80 → not present status"); + expect(html).not.toContain("-25"); + }); +}); + +describe("crashed runner on the actions and trends pages (#107)", () => { + // core.ts's stub for a runner that threw: status failed, a placeholder 0/F. + const crashed: CheckResult = { + name: "lint", + score: 0, + grade: "F", + details: { skipped: true, status: "failed", reason: "runner error: boom" }, + issues: [], + duration: 0, + }; + (crashed as CheckResult & { status: string }).status = "failed"; + const lint = (score: number): CheckResult => ({ + name: "lint", + score, + grade: "C", + details: { status: "failed" }, + issues: [], + duration: 1, + }); + const structure = (score: number): CheckResult => ({ name: "structure", score, grade: "B", details: {}, issues: [], duration: 1 }); + const trendRows = (html: string) => + [...html.matchAll(/
(.*?)<\/div>/g)].map((m) => + m[1] + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); + function writeHistory(dir: string, entries: { timestamp: string; checks: CheckResult[] }[]): string { + const historyDir = join(dir, "history"); + mkdirSync(historyDir, { recursive: true }); + for (const { timestamp, checks } of entries) { + const report = { ...makeReport(dir, checks), timestamp }; + writeFileSync(join(historyDir, `${timestamp}.json`), JSON.stringify(buildReportHistorySnapshot(report))); + } + return historyDir; + } + + it("actions page: 72 → runner error is a status change, not lint -72", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const html = generatePages(makeReport(dir, [crashed]), undefined, makeReport(dir, [lint(72)])).get("actions.html")!; + expect(html).toContain("lint: 72 → failed (runner error)"); + expect(html).not.toContain("lint -72"); + }); + + it("actions page: runner error → 72 is a status change, not lint +72", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const html = generatePages(makeReport(dir, [lint(72)]), undefined, makeReport(dir, [crashed])).get("actions.html")!; + expect(html).toContain("lint: failed (runner error) → 72"); + expect(html).not.toContain("lint +72"); + }); + + it("trends page: a crashed scan is a gap in the chart and a transition in the table", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const historyDir = writeHistory(dir, [ + { timestamp: "2026-10-01T00:00:00.000Z", checks: [lint(64), structure(80)] }, + { timestamp: "2026-10-02T00:00:00.000Z", checks: [lint(72), structure(84)] }, + { timestamp: "2026-10-03T00:00:00.000Z", checks: [crashed, structure(86)] }, + ]); + // The snapshot keeps the crash only in details (no top-level status). + const snapshot = JSON.parse(readFileSync(join(historyDir, "2026-10-03T00:00:00.000Z.json"), "utf-8")); + expect(snapshot.checks[0]).not.toHaveProperty("status"); + expect(snapshot.checks[0].details.reason).toBe("runner error: boom"); + + const html = generatePages(makeReport(dir, [crashed, structure(86)]), historyDir).get("trends.html")!; + const rows = trendRows(html); + expect(rows).toContain("lint 64 → failed (runner error) status"); + expect(rows).toContain("structure 80 → 86 +6"); + expect(html).not.toContain("-64"); + expect(html).not.toContain("-72"); + // The lint card shows one check on its own, so it carries the full crash reason; + // the first → latest row above keeps the short transition token. + expect(html).toMatch(/lint<\/span>failed \(runner error: boom\)<\/span>/); + expect(rows.join("\n")).not.toContain("boom"); + // The lint card plots only the two scans that produced a score. + const lintCard = html.slice(html.indexOf('lint')).split('
')[0]; + expect(lintCard).toContain("2026-10-01 — 64"); + expect(lintCard).toContain("2026-10-02 — 72"); + expect(lintCard).not.toContain("2026-10-03"); + }); + + it("trends page: runner error → 72 is a transition, not +72", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const historyDir = writeHistory(dir, [ + { timestamp: "2026-10-01T00:00:00.000Z", checks: [crashed] }, + { timestamp: "2026-10-02T00:00:00.000Z", checks: [lint(70)] }, + { timestamp: "2026-10-03T00:00:00.000Z", checks: [lint(72)] }, + ]); + const html = generatePages(makeReport(dir, [lint(72)]), historyDir).get("trends.html")!; + expect(trendRows(html)).toContain("lint failed (runner error) → 72 status"); + expect(html).not.toContain("+72"); + }); +}); + +describe("actions page does not count issues of a check that did not run on one side (#107)", () => { + const issues = ["a", "b", "c"].map((f) => ({ severity: "warning" as const, message: `issue in ${f}`, file: `src/${f}.ts`, rule: "r" })); + const ran: CheckResult = { name: "lint", score: 72, grade: "C", details: { status: "failed" }, issues, duration: 1 }; + const crashed: CheckResult = { + name: "lint", + score: 0, + grade: "F", + details: { skipped: true, status: "failed", reason: "runner error: boom" }, + issues: [], + duration: 0, + }; + const unavailable: CheckResult = { + name: "lint", + score: 100, + grade: "A", + details: { skipped: true, unavailable: true, status: "unavailable" }, + issues: [], + duration: 1, + }; + const cases = Object.entries({ crashed, unavailable, dropped: undefined }).flatMap(([kind, other]) => [ + { title: `ran (3 issues) → ${kind}`, prev: [ran], curr: other ? [other] : [] }, + { title: `${kind} → ran (3 issues)`, prev: other ? [other] : [], curr: [ran] }, + ]); + + it.each(cases)("$title: 0 fixed, 0 new", ({ prev, curr }) => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-report-")); + const html = generatePages(makeReport(dir, curr), undefined, makeReport(dir, prev)).get("actions.html")!; + const banner = html.slice(html.indexOf('
')); + expect(banner).toContain('0 fixed'); + expect(banner).toContain('0 new'); + expect(banner).not.toContain("Fixed ("); + expect(banner).not.toContain("New ("); + expect(banner).toContain("Status changes:"); + }); +}); + describe("report file links", () => { const issueCheck = (): CheckResult => ({ name: "lint", diff --git a/src/report/pages.ts b/src/report/pages.ts index e38f7f9..917c7cb 100644 --- a/src/report/pages.ts +++ b/src/report/pages.ts @@ -4,9 +4,18 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { type CheckMeta, getCheckMeta } from "../check-meta.js"; import { suggestFix } from "../commands/shared.js"; -import type { ScanDelta } from "../delta.js"; +import { + type CheckSide, + compareCheckSides, + formatCheckSide, + formatCheckState, + formatTransition, + type ScanDelta, + scoreChanges, + statusTransitions, +} from "../delta.js"; import { buildCoverageMapInput, generateCoverageMap } from "../diagrams/coverage.js"; -import { loadHistory } from "../history.js"; +import { type HistoryEntry, loadHistory } from "../history.js"; import { generateArchSVG, generateDSM, @@ -698,59 +707,75 @@ export function trendsPage(historyDir: string | undefined): string { { width: 700, height: 150 }, ); - // Collect all check names from latest entry + // Check names from the latest entry, plus any the first entry had (so a + // dropped check reads "72 → not present" instead of vanishing). + const first = history[0]; const latest = history[history.length - 1]; - const checkNames = [...latest.checkScores.keys()]; + const checkNames = [...latest.checkStates.keys()]; + for (const name of first.checkStates.keys()) if (!checkNames.includes(name)) checkNames.push(name); - // Per-check mini charts + // Per-check mini charts. Only scans where the check ran are plotted: a + // not-run check's placeholder 100 is not a score, so it is a gap (#107). const checkCharts = checkNames .map((name) => { - const data = history.map((h) => ({ score: h.checkScores.get(name) ?? 0, timestamp: h.timestamp })).filter((d) => d.score > 0); + const data = history.flatMap((h) => { + const score = h.checkScores.get(name); + return score === undefined ? [] : [{ score, timestamp: h.timestamp }]; + }); if (data.length < 2) return ""; - const current = data[data.length - 1].score; - const prev = data[data.length - 2].score; - const delta = current - prev; - const deltaStr = - delta > 0 - ? `+${delta}` - : delta < 0 - ? `${delta}` - : `=`; - const color = current >= 90 ? "var(--pass)" : current >= 75 ? "#84cc16" : current >= 60 ? "var(--warn)" : "var(--fail)"; - + const latestSide = historySide(latest, name); const chart = buildTimeline(data, { width: 300, height: 60 }); + let headline: string; + if (latestSide.state === "ran") { + const current = latestSide.score ?? 0; + const delta = current - data[data.length - 2].score; + const deltaStr = + delta > 0 + ? `+${delta}` + : delta < 0 + ? `${delta}` + : `=`; + const color = current >= 90 ? "var(--pass)" : current >= 75 ? "#84cc16" : current >= 60 ? "var(--warn)" : "var(--fail)"; + headline = `${current}${deltaStr}`; + } else { + // A single check's state on its own: show the crash reason in full. + headline = `${e(formatCheckState(latestSide, latest.checkReasons.get(name)))}`; + } return `
-
${e(name)}${current}${deltaStr}
+
${e(name)}${headline}
${chart}
`; }) .filter(Boolean) .join(""); - // Score delta table - const deltaRows = checkNames - .map((name) => { - const scores = history.map((h) => h.checkScores.get(name) ?? 0); - const first = scores.find((s) => s > 0) ?? 0; - const last = scores[scores.length - 1]; - const delta = last - first; - if (first === 0 && last === 0) return ""; - return { name, first, last, delta }; - }) - .filter(Boolean) - .sort((a, b) => (b as { delta: number }).delta - (a as { delta: number }).delta) as { - name: string; - first: number; - last: number; - delta: number; - }[]; + // First → latest: a numeric delta only when the check ran at both ends; + // otherwise a status transition such as "64 → unavailable". + type DeltaRow = { name: string; before: CheckSide; after: CheckSide; delta: number; transition: boolean }; + const deltaRows: DeltaRow[] = []; + for (const name of checkNames) { + const firstEntry = history.find((h) => h.checkStates.has(name)); + if (!firstEntry) continue; + const before = historySide(firstEntry, name); + const after = historySide(latest, name); + const { delta, transition } = compareCheckSides(before, after); + if (before.state !== "ran" && !transition) continue; // never ran: nothing to show + deltaRows.push({ name, before, after, delta, transition: !!transition }); + } + deltaRows.sort((a, b) => Number(a.transition) - Number(b.transition) || b.delta - a.delta); const deltaTable = deltaRows .map((r) => { - const clr = r.delta > 0 ? "var(--pass)" : r.delta < 0 ? "var(--fail)" : "var(--muted)"; - return `
${e(r.name)}${r.first}\u2192${r.last}${r.delta > 0 ? "+" : ""}${r.delta}
`; + const cell = (side: CheckSide) => + side.state === "ran" + ? `${side.score}` + : `${e(formatCheckSide(side))}`; + const tail = r.transition + ? `status` + : `${r.delta > 0 ? "+" : ""}${r.delta}`; + return `
${e(r.name)}${cell(r.before)}\u2192${cell(r.after)}${tail}
`; }) .join(""); @@ -768,6 +793,15 @@ export function trendsPage(historyDir: string | undefined): string {
${checkCharts}
`; } +/** One check's side in a history entry: its score when it ran, else its run state. */ +function historySide(entry: HistoryEntry, name: string): CheckSide { + const state = entry.checkStates.get(name); + if (!state) return { state: "absent" }; + if (state !== "ran") return { state }; + const score = entry.checkScores.get(name); + return score === undefined ? { state: "absent" } : { state, score }; +} + // ── Feature Map (Pro) ──────────────────────────────────── export function featureMapPage(deadPatternsCheck: CheckResult | undefined, fl: FL): string { @@ -1001,7 +1035,7 @@ export function actionsPage(allChecks: CheckResult[], fl: FL, linter: string, de const dArrow = delta.scoreDelta > 0 ? "▲" : delta.scoreDelta < 0 ? "▼" : "▬"; // Per-check changes - const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta); + const changed = scoreChanges(delta); const checkDeltas = changed .slice(0, 10) .map((c) => { @@ -1009,6 +1043,12 @@ export function actionsPage(allChecks: CheckResult[], fl: FL, linter: string, de return `${c.name} ${c.delta > 0 ? "+" : ""}${c.delta}`; }) .join(""); + // Checks that started or stopped running — listed apart from score changes, + // because a not-run check's placeholder 100 is not a score (#107). + const statusChips = statusTransitions(delta) + .slice(0, 10) + .map((c) => `${e(c.name)}: ${e(formatTransition(c.transition))}`) + .join(""); // Fixed issues list let fixedList = ""; @@ -1047,6 +1087,7 @@ export function actionsPage(allChecks: CheckResult[], fl: FL, linter: string, de ${delta.after.issueCount} remaining
${checkDeltas ? `
${checkDeltas}
` : ""} + ${statusChips ? `
Status changes: ${statusChips}
` : ""} ${fixedList}${newList}
`; } diff --git a/src/report/styles.ts b/src/report/styles.ts index 4c4a04d..b21c7bf 100644 --- a/src/report/styles.ts +++ b/src/report/styles.ts @@ -226,6 +226,8 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing: .trend-row-val{width:2rem;text-align:center;color:var(--muted)} .trend-row-arrow{color:var(--muted);font-size:0.6rem} .trend-row-delta{width:2.5rem;text-align:right;font-weight:700} +.trend-row-state{width:auto;min-width:2rem;font-weight:400;font-style:italic} +.trend-status{font-size:0.75rem;font-style:italic} .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)} .footer a{color:var(--muted)} diff --git a/src/trend.test.ts b/src/trend.test.ts index 65287a8..3ad8fcf 100644 --- a/src/trend.test.ts +++ b/src/trend.test.ts @@ -2,7 +2,8 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { computeTrend, formatTrend, trendHTML } from "./trend.js"; +import { formatTransition } from "./delta.js"; +import { computeTrend, formatTrend } from "./trend.js"; import type { VibeReport } from "./types.js"; function makeReport(score: number, checks: { name: string; score: number; issues: number }[]): VibeReport { @@ -132,6 +133,109 @@ describe("computeTrend", () => { }); }); +describe("computeTrend not-run checks (#107)", () => { + it("emits a status transition instead of a numeric delta when either side did not run", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + const prev = makeReport(70, [{ name: "lint", score: 100, issues: 0 }]); + prev.checks[0].details = { skipped: true, unavailable: true, status: "unavailable" }; + writeFileSync(join(dir, "report.json"), JSON.stringify(prev)); + const curr = makeReport(70, [{ name: "lint", score: 72, issues: 0 }]); + curr.checks[0].details = { status: "failed" }; + + const trend = computeTrend(curr, dir)!; + const lint = trend.checkDeltas.find((d) => d.name === "lint")!; + expect(lint.delta).toBe(0); + expect(lint.transition).toEqual({ before: { state: "unavailable" }, after: { state: "ran", score: 72 } }); + + expect(lint).toMatchObject({ prev: null, curr: 72 }); + expect(formatTransition(lint.transition!)).toBe("unavailable → 72"); + }); + + it("does not report a removed tool as +N", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + const prev = makeReport(70, [{ name: "lint", score: 64, issues: 0 }]); + writeFileSync(join(dir, "report.json"), JSON.stringify(prev)); + const curr = makeReport(70, [{ name: "lint", score: 100, issues: 0 }]); + curr.checks[0].details = { skipped: true, comingSoon: true }; + + const trend = computeTrend(curr, dir)!; + expect(trend.checkDeltas[0]).toMatchObject({ prev: 64, curr: null, delta: 0 }); + expect(formatTransition(trend.checkDeltas[0].transition!)).toBe("64 → unavailable"); + }); + + it("reports a check that disappeared as a transition", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + writeFileSync(join(dir, "report.json"), JSON.stringify(makeReport(70, [{ name: "lint", score: 72, issues: 2 }]))); + const trend = computeTrend(makeReport(70, []), dir)!; + expect(trend.checkDeltas).toHaveLength(1); + expect(trend.checkDeltas[0]).toMatchObject({ name: "lint", prev: 72, curr: null, delta: 0 }); + expect(formatTransition(trend.checkDeltas[0].transition!)).toBe("72 → not present"); + expect(trend.fixedIssues).toBe(0); + }); +}); + +describe("computeTrend crashed runner (#107)", () => { + const crash = { skipped: true, status: "failed", reason: "runner error: boom" }; + + it("72 → runner error is a transition, not -72", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + writeFileSync(join(dir, "report.json"), JSON.stringify(makeReport(70, [{ name: "lint", score: 72, issues: 0 }]))); + const curr = makeReport(60, [{ name: "lint", score: 0, issues: 0 }]); + curr.checks[0].details = crash; + + const lint = computeTrend(curr, dir)!.checkDeltas.find((d) => d.name === "lint")!; + expect(lint).toMatchObject({ prev: 72, curr: null, delta: 0 }); + expect(lint.transition).toEqual({ before: { state: "ran", score: 72 }, after: { state: "runner-error" } }); + expect(formatTransition(lint.transition!)).toBe("72 → failed (runner error)"); + }); + + it("runner error → 72 is a transition, not +72", () => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + const prev = makeReport(60, [{ name: "lint", score: 0, issues: 0 }]); + prev.checks[0].details = crash; + writeFileSync(join(dir, "report.json"), JSON.stringify(prev)); + + const lint = computeTrend(makeReport(70, [{ name: "lint", score: 72, issues: 0 }]), dir)!.checkDeltas[0]; + expect(lint).toMatchObject({ prev: null, curr: 72, delta: 0 }); + expect(formatTransition(lint.transition!)).toBe("failed (runner error) → 72"); + }); +}); + +describe("computeTrend does not diff issues of a check that did not run on one side (#107)", () => { + const crashed = { skipped: true, status: "failed", reason: "runner error: boom" }; + const unavailable = { skipped: true, unavailable: true, status: "unavailable" }; + const notRun = (details: Record | null): VibeReport => { + if (!details) return makeReport(70, [{ name: "structure", score: 80, issues: 0 }]); + const r = makeReport(70, [ + { name: "lint", score: 0, issues: 0 }, + { name: "structure", score: 80, issues: 0 }, + ]); + r.checks[0].details = details; + return r; + }; + const ran = () => + makeReport(70, [ + { name: "lint", score: 72, issues: 3 }, + { name: "structure", score: 80, issues: 0 }, + ]); + const cases = Object.entries({ crashed, unavailable, dropped: null }).flatMap(([kind, details]) => [ + { title: `ran (3 issues) → ${kind}`, prev: ran, curr: () => notRun(details) }, + { title: `${kind} → ran (3 issues)`, prev: () => notRun(details), curr: ran }, + ]); + + it.each(cases)("$title: 0 fixed, 0 new", ({ prev, curr }) => { + const dir = mkdtempSync(join(tmpdir(), "vcqa-trend-")); + writeFileSync(join(dir, "report.json"), JSON.stringify(prev())); + const trend = computeTrend(curr(), dir)!; + expect(trend.fixedIssues).toBe(0); + expect(trend.newIssues).toBe(0); + expect(trend.fixed).toEqual([]); + expect(trend.introduced).toEqual([]); + expect(trend.checkDeltas.find((d) => d.name === "lint")!.transition).toBeDefined(); + expect(formatTrend(trend)).not.toMatch(/fixed|new/); + }); +}); + describe("formatTrend", () => { it("formats improvement", () => { const out = formatTrend({ @@ -168,19 +272,3 @@ describe("formatTrend", () => { expect(out).toMatch(/[▁▂▃▄▅▆▇█]/); }); }); - -describe("trendHTML", () => { - it("generates valid HTML", () => { - const html = trendHTML({ - scoreDelta: 5, - checkDeltas: [{ name: "lint", prev: 70, curr: 75, delta: 5 }], - newIssues: 0, - fixedIssues: 2, - prevTimestamp: "2026-05-29T00:00:00Z", - }); - expect(html).toContain("+5 pts"); - expect(html).toContain("2 fixed"); - expect(html).toContain("lint +5"); - expect(html).toContain("var(--pass)"); - }); -}); diff --git a/src/trend.ts b/src/trend.ts index 7baeed2..135d2cd 100644 --- a/src/trend.ts +++ b/src/trend.ts @@ -2,12 +2,17 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { checkSide, compareCheckSides, issuesComparable, type StatusTransition } from "./delta.js"; import { comparableFingerprints, type IssueSnapshot, issueSnapshot } from "./issue-fingerprint.js"; import type { Issue, VibeReport } from "./types.js"; export interface TrendDelta { scoreDelta: number; // positive = improved - checkDeltas: { name: string; prev: number; curr: number; delta: number }[]; + /** + * `delta` is 0 and `transition` is set when either side did not run or its + * runner crashed (#107). `prev`/`curr` are null for such a side, or an absent one. + */ + checkDeltas: { name: string; prev: number | null; curr: number | null; delta: number; transition?: StatusTransition }[]; newIssues: number; fixedIssues: number; introduced?: IssueSnapshot[]; @@ -31,21 +36,29 @@ export function computeTrend(report: VibeReport, outputDir: string): TrendDelta const scoreDelta = report.score - prev.score; const checkDeltas: TrendDelta["checkDeltas"] = []; - for (const curr of report.checks) { - const prevCheck = prev.checks.find((c) => c.name === curr.name); - const prevScore = prevCheck?.score ?? 0; + // Union of names, so a check that disappeared reads "72 → not present". + // Issues are compared only for checks that ran in both scans (#107). + const comparable = new Set(); + const names = report.checks.map((c) => c.name); + for (const c of prev.checks) if (!names.includes(c.name)) names.push(c.name); + for (const name of names) { + const prevSide = checkSide(prev.checks.find((c) => c.name === name)); + const currSide = checkSide(report.checks.find((c) => c.name === name)); + if (issuesComparable(prevSide, currSide)) comparable.add(name); + const { delta, transition } = compareCheckSides(prevSide, currSide); checkDeltas.push({ - name: curr.name, - prev: prevScore, - curr: curr.score, - delta: curr.score - prevScore, + name, + prev: prevSide.score ?? null, + curr: currSide.score ?? null, + delta, + ...(transition ? { transition } : {}), }); } // Across a fingerprint-version boundary both sides are re-keyed as v1 (#97). const keyOf = comparableFingerprints(prev, report); - const prevIssues = issueMultiset(prev, keyOf); - const currIssues = issueMultiset(report, keyOf); + const prevIssues = issueMultiset(prev, comparable, keyOf); + const currIssues = issueMultiset(report, comparable, keyOf); const introduced = surplus(currIssues, prevIssues); const fixed = surplus(prevIssues, currIssues); const newIssues = introduced.length; @@ -54,10 +67,16 @@ export function computeTrend(report: VibeReport, outputDir: string): TrendDelta return { scoreDelta, checkDeltas, newIssues, fixedIssues, introduced, fixed, prevTimestamp: prev.timestamp }; } -/** Group issues by fingerprint, keeping every occurrence (a multiset, as in delta.ts). */ -function issueMultiset(report: VibeReport, keyOf: (checkName: string, issue: Issue) => string): Map { +/** Group issues of comparable checks by fingerprint, keeping every occurrence + * (a multiset, as in delta.ts). */ +function issueMultiset( + report: VibeReport, + comparable: Set, + keyOf: (checkName: string, issue: Issue) => string, +): Map { const out = new Map(); for (const check of report.checks) { + if (!comparable.has(check.name)) continue; for (const issue of check.issues) { const fp = keyOf(check.name, issue); const list = out.get(fp); @@ -111,22 +130,3 @@ function terminalSparkline(values: number[]): string { }) .join(""); } - -/** Render trend HTML for the report. */ -export function trendHTML(trend: TrendDelta): string { - const arrow = trend.scoreDelta > 0 ? "▲" : trend.scoreDelta < 0 ? "▼" : "▬"; - const color = trend.scoreDelta > 0 ? "var(--pass)" : trend.scoreDelta < 0 ? "var(--fail)" : "var(--muted)"; - - let deltas = ""; - for (const d of trend.checkDeltas) { - if (d.delta === 0) continue; - const c = d.delta > 0 ? "var(--pass)" : "var(--fail)"; - deltas += `${d.name} ${d.delta > 0 ? "+" : ""}${d.delta}`; - } - - return `
-
${arrow}${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta} ptsvs ${trend.prevTimestamp.split("T")[0]}
-
${trend.fixedIssues > 0 ? `${trend.fixedIssues} fixed` : ""}${trend.newIssues > 0 ? `${trend.newIssues} new` : ""}
-${deltas ? `
${deltas}
` : ""} -
`; -}