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 `