diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a9ea7..623aa48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## Unreleased + +### 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 +time the number changed, duplication identity included line numbers, and +repeated findings in one file shared one fingerprint (#97). +- **Fixed**: `trend` counts findings per fingerprint as a multiset, as the delta + report already did — fixing one of two identical findings now reports one + fixed instead of nothing. +- **Changed**: reports now carry `meta.fingerprintVersion: 2`, and fingerprints + are computed differently: + - file-scoped measured rules (standards `large-file`, context + `high-token-count`, architecture `high-fan-out`, performance + `barrel-import`) are keyed by file, not message; + - complexity findings by file + function name; + - duplication by the sorted pair of paths without `:line`, plus a hash of + the clone snippet; + - these runners emit the key as `Issue.subject`; + - other findings that point at a line add a hash of that source line, and + only findings that are still identical get an occurrence ordinal. Vue and + Svelte files are not anchored — their runners report lines relative to + the extracted `\n`; + // Svelte allows markup before the script; put it there so the edit lands above. + const svelte = (label: string) => `
\n${rows(label)}\n
\n\n\n`; + + for (const [ext, sfc] of [ + ["vue", vue], + ["svelte", svelte], + ] as const) { + it(`a template-only edit to a .${ext} file reports 0 new / 0 fixed`, async () => { + const file = `src/Comp.${ext}`; + const dir = project({ [file]: sfc("row") }); + const opts = { checks: ["type-safety", "error-handling"], skipTests: true }; + const before = await scan(dir, opts); + const found = before.checks.flatMap((c) => c.issues).filter((i) => i.file === file); + expect(found.length).toBeGreaterThanOrEqual(2); // the fixture really produces script findings + expect(new Set(found.map((i) => (i as FingerprintedIssue).fingerprint)).size).toBe(found.length); // repeats still distinct + write(dir, { [file]: sfc("row zero") }); + const after = await scan(dir, opts); + const delta = computeDelta(before, after); + expect(delta.introduced).toHaveLength(0); + expect(delta.fixed).toHaveLength(0); + }); + } +}); + +function check(name: string, issues: Issue[]): CheckResult { + return { name, score: 80, grade: "B", details: { status: "passed" }, issues, duration: 1 }; +} + +function report(checks: CheckResult[]): VibeReport { + return { + version: "0.56.0", + timestamp: "2026-10-05T00:00:00Z", + score: 80, + grade: "B", + checks, + meta: { cwd: "/tmp", node: "v22", duration: 1, stack: TS_STACK, repoUrl: null, branch: "main" }, + }; +} + +function v2Report(checks: CheckResult[]): VibeReport { + const r = report(checks); + r.meta.fingerprintVersion = FINGERPRINT_VERSION; + return r; +} + +function trendBetween(prev: VibeReport, curr: VibeReport) { + const dir = mkdtempSync(join(tmpdir(), "vcqa-fp2-trend-")); + dirs.push(dir); + writeFileSync(join(dir, "report.json"), JSON.stringify(prev)); + const trend = computeTrend(curr, dir); + expect(trend).not.toBeNull(); + return trend!; +} diff --git a/src/issue-fingerprint.ts b/src/issue-fingerprint.ts index abc38c6..0944afd 100644 --- a/src/issue-fingerprint.ts +++ b/src/issue-fingerprint.ts @@ -1,7 +1,34 @@ import { createHash } from "node:crypto"; +import { type FileInventory, readInventoryText } from "./file-inventory.js"; import type { Issue } from "./types.js"; -export type FingerprintedIssue = Issue & { fingerprint?: string }; +/** + * Fingerprint scheme written into `meta.fingerprintVersion` (#97). + * + * - v1 (reports without the field): check + rule + normalised path + lower-cased + * message. A measurement in the message ("65 lines (max 60)") re-identified the + * finding whenever the number moved, and repeated findings collided. + * - v2: per-runner `subject` replaces path + message where a runner declares one; + * other line-anchored findings add a hash of the source line they point at, and + * only findings that are still identical get an occurrence ordinal. + */ +export const FINGERPRINT_VERSION = 2; + +/** + * Issue fields declared by @vibecodeqa/schema 0.6.0 (`fingerprint`, `subject`) + * that the pinned 0.5.0 does not declare yet. They survive validation through + * `.passthrough()`; when the dependency moves to 0.6.0 this becomes `Issue`. + */ +export type FingerprintedIssue = Issue & { + fingerprint?: string; + /** + * What the finding is about, as the runner identifies it — e.g. a file for a + * file-scoped measured rule, a file + function for complexity. When set, it + * replaces path + message in the v2 key, so a changed measurement keeps the + * fingerprint. Self-contained: include the path when the path is part of it. + */ + subject?: string; +}; export interface IssueSnapshot { fingerprint: string; @@ -13,13 +40,74 @@ export interface IssueSnapshot { message: string; } +/** Reads one 1-based line of a file's raw text, or undefined when unavailable. */ +export type SourceLineReader = (file: string, line: number) => string | undefined; + +/** + * Line reader over the scan's FileInventory. Reads the raw file, which is what a + * reported line number means for every file the anchor is used on — SFCs are + * never anchored (see {@link contentAnchor}). Files are read at most once per + * scan. A file that cannot be read (deleted, permissions) yields no line, so its + * findings fall back to the unanchored key; they churn once, when it becomes + * readable again, rather than failing the scan. + */ +export function inventoryLineReader(inventory: FileInventory): SourceLineReader { + const byPath = new Map([...inventory.files, ...inventory.ignoredFiles].map((f) => [normalizePath(f.path), f])); + const cache = new Map(); + return (file, line) => { + let lines = cache.get(file); + if (lines === undefined) { + const entry = byPath.get(file); + const text = entry ? readInventoryText(entry) : ""; + lines = text ? text.split(/\r?\n/) : null; + cache.set(file, lines); + } + return lines ? lines[line - 1] : undefined; + }; +} + +/** v1 fingerprint: check + rule + path + message. Still what v1 reports hold. */ export function fingerprintIssue(checkName: string, issue: Issue): string { - const parts = [checkName, issue.rule ?? "", normalizePath(issue.file), normalizeMessage(issue.message)]; - return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 16); + return hash([checkName, issue.rule ?? "", normalizePath(issue.file), normalizeMessage(issue.message)]); } -export function withIssueFingerprints(checkName: string, issues: Issue[]): FingerprintedIssue[] { - return issues.map((issue) => ({ ...issue, fingerprint: readIssueFingerprint(checkName, issue) })); +/** Alias kept explicit for the v1/v2 boundary in trend.ts and delta.ts. */ +export const fingerprintIssueV1 = fingerprintIssue; + +/** + * Attach v2 fingerprints to one check's issues. Context matters in v2 (the + * source line, and which other findings are identical), so this works on the + * whole list rather than per issue. An issue that already carries a + * fingerprint keeps it. + */ +export function withIssueFingerprints(checkName: string, issues: Issue[], readLine?: SourceLineReader): FingerprintedIssue[] { + const keys = issues.map((issue) => v2Key(checkName, issue as FingerprintedIssue, readLine)); + + // Group still-identical keys; within a group, order by line (then emission) + // and suffix every occurrence after the first with its ordinal. The first + // keeps the un-suffixed fingerprint, so 1 → 2 occurrences leaves it intact. + const groups = new Map(); + keys.forEach((key, i) => { + const id = key.join("\0"); + const g = groups.get(id); + if (g) g.push(i); + else groups.set(id, [i]); + }); + const ordinals = new Array(issues.length).fill(0); + for (const idxs of groups.values()) { + if (idxs.length < 2) continue; + const sorted = [...idxs].sort((a, b) => (issues[a].line ?? 0) - (issues[b].line ?? 0) || a - b); + sorted.forEach((idx, ord) => { + ordinals[idx] = ord; + }); + } + + return issues.map((issue, i) => { + const existing = (issue as FingerprintedIssue).fingerprint; + if (typeof existing === "string" && existing.length > 0) return { ...issue }; + const fingerprint = ordinals[i] === 0 ? hash(keys[i]) : hash([...keys[i], `#${ordinals[i]}`]); + return { ...issue, fingerprint }; + }); } export function issueSnapshot(checkName: string, issue: Issue): IssueSnapshot { @@ -34,15 +122,76 @@ export function issueSnapshot(checkName: string, issue: Issue): IssueSnapshot { }; } +/** + * The stored fingerprint, else a v1 recomputation. Only valid for comparing two + * reports of the same fingerprint version — across versions use + * {@link comparableFingerprints}. + */ export function readIssueFingerprint(checkName: string, issue: Issue): string { const fp = (issue as FingerprintedIssue).fingerprint; return typeof fp === "string" && fp.length > 0 ? fp : fingerprintIssue(checkName, issue); } -function normalizePath(path: string | undefined): string { +/** A report's fingerprint version; reports written before v2 carry none (= 1). */ +export function reportFingerprintVersion(report: { meta?: unknown }): number { + const v = (report.meta as { fingerprintVersion?: unknown } | undefined)?.fingerprintVersion; + return typeof v === "number" && Number.isFinite(v) ? v : 1; +} + +/** + * How to key issues when comparing two reports. Same version: the stored + * fingerprints. Different versions: recompute v1 on both sides from + * check/rule/file/message — preferring the stored value would compare a v1 + * hash with a v2 hash and report every finding as fixed + new. + */ +export function comparableFingerprints(a: { meta?: unknown }, b: { meta?: unknown }): (checkName: string, issue: Issue) => string { + return reportFingerprintVersion(a) === reportFingerprintVersion(b) ? readIssueFingerprint : fingerprintIssueV1; +} + +/** Short hash of a whitespace-normalised clone snippet (duplication subject). */ +export function snippetDigest(snippet: string): string { + return createHash("sha1").update(snippet.replace(/\s+/g, " ").trim()).digest("hex").slice(0, 12); +} + +export function normalizePath(path: string | undefined): string { return (path ?? "").replace(/\\/g, "/").replace(/^\.\//, "").trim(); } +function v2Key(checkName: string, issue: FingerprintedIssue, readLine?: SourceLineReader): string[] { + const rule = issue.rule ?? ""; + if (typeof issue.subject === "string" && issue.subject.length > 0) { + return ["v2", checkName, rule, "subject", issue.subject]; + } + const base = [checkName, rule, normalizePath(issue.file), normalizeMessage(issue.message)]; + const anchor = contentAnchor(issue, readLine); + // No anchor and no repeat: identical to the v1 key. + return anchor ? [...base, `@${anchor}`] : base; +} + +/** + * Vue/Svelte single-file components carry no content anchor. Their line numbers + * have no single provenance: most runners analyse `SourceFile.content`, the + * extracted `