From 57693ec64b516a1951f7fc2628c8d6defbd89d71 Mon Sep 17 00:00:00 2001 From: Sergey Ivochkin Date: Mon, 5 Oct 2026 14:13:29 +1100 Subject: [PATCH 1/7] =?UTF-8?q?feat(fingerprint):=20fingerprint=20v2=20?= =?UTF-8?q?=E2=80=94=20per-runner=20subjects,=20content=20anchors,=20versi?= =?UTF-8?q?on=20boundary=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 fingerprints hashed check + rule + path + message, so a measurement in the message re-identified a finding whenever the number moved, duplication identity included line numbers, and repeated findings in one file collided. - Runners that report a measurement emit `Issue.subject`, which replaces path + message in the key: standards large-file, context high-token-count, architecture high-fan-out and performance barrel-import by file; complexity by file + function name; duplication by the sorted path pair without `:line` plus a hash of the clone snippet. No global digit rewriting. - Other findings with file + line add a hash of the whitespace- normalised raw source line, read through the FileInventory; only findings that are still identical get an occurrence ordinal (line order, else emission order). The first occurrence keeps the un-suffixed fingerprint. - Reports carry meta.fingerprintVersion = 2. trend and delta recompute v1 on both sides when the two reports' versions differ, so the first scan after upgrading reports no churn. - Issue.subject and meta.fingerprintVersion are typed locally; they are declared by schema 0.6.0 and pass through 0.5.0 via .passthrough(). - CHANGELOG: Unreleased note on the fingerprint version change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SmtSK7qDTyXhimJ9grap88 --- CHANGELOG.md | 29 +++ src/core.ts | 10 +- src/delta.ts | 10 +- src/fingerprint-v2.test.ts | 349 ++++++++++++++++++++++++++++++++++++ src/issue-fingerprint.ts | 145 ++++++++++++++- src/runners/architecture.ts | 7 +- src/runners/complexity.ts | 8 +- src/runners/context.ts | 7 +- src/runners/duplication.ts | 19 +- src/runners/performance.ts | 7 +- src/runners/standards.ts | 8 +- src/trend.ts | 14 +- src/types.ts | 2 + 13 files changed, 580 insertions(+), 35 deletions(-) create mode 100644 src/fingerprint-v2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a9ea7..4adcd1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # 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. +- **Compatibility**: comparing a v1 report (no `fingerprintVersion`) with a v2 + report recomputes v1 fingerprints on both sides, so the first scan after the + upgrade shows no churn. Anything that stores fingerprints and matches them + across scans — rather than going through `trend`/`delta` — will see v2 values + differ from v1 for subject-keyed, line-anchored and repeated findings, and + must treat a `fingerprintVersion` change as a re-baseline. Project-level aggregate findings (for example "N unused files") + still keep their message in the key, as before. + ## 0.56.0 (2026-08-18) ### A linter that cannot read your language no longer grades it diff --git a/src/core.ts b/src/core.ts index 7c9fd9d..214497d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -14,7 +14,7 @@ import { getCheckIgnore, isCheckEnabled, loadConfig, type VcqaConfig } from "./c import { detectRepoUrl, detectStack, detectWorkspace } from "./detect.js"; import { buildFileInventory } from "./file-inventory.js"; import { setGlobalIgnore, setGlobalIgnoreNames, setGlobalScanPolicy, setGlobalSrcRoots } from "./fs-utils.js"; -import { withIssueFingerprints } from "./issue-fingerprint.js"; +import { FINGERPRINT_VERSION, inventoryLineReader, type SourceLineReader, withIssueFingerprints } from "./issue-fingerprint.js"; import { runAccessibility } from "./runners/accessibility.js"; import { runArchitecture } from "./runners/architecture.js"; import { runBestPractices } from "./runners/best-practices.js"; @@ -117,6 +117,7 @@ export async function scan(cwd: string, options: ScanOptions = {}): Promise s + c.issues.length, 0); @@ -48,6 +43,9 @@ export function computeDelta(before: VibeReport, after: VibeReport): ScanDelta { const checks: CheckDelta[] = []; const allFixed: DeltaIssue[] = []; const allIntroduced: DeltaIssue[] = []; + // Stored fingerprints when both reports share a fingerprint version; v1 + // 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); diff --git a/src/fingerprint-v2.test.ts b/src/fingerprint-v2.test.ts new file mode 100644 index 0000000..c4bd366 --- /dev/null +++ b/src/fingerprint-v2.test.ts @@ -0,0 +1,349 @@ +/** Fingerprint v2 (#97): per-runner subjects, content anchors, and the v1/v2 boundary. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { scan } from "./core.js"; +import { computeDelta } from "./delta.js"; +import { setGlobalSrcRoots } from "./fs-utils.js"; +import { + FINGERPRINT_VERSION, + type FingerprintedIssue, + fingerprintIssue, + type SourceLineReader, + withIssueFingerprints, +} from "./issue-fingerprint.js"; +import { runArchitecture } from "./runners/architecture.js"; +import { runComplexity } from "./runners/complexity.js"; +import { runContext } from "./runners/context.js"; +import { runDuplication } from "./runners/duplication.js"; +import { runPerformance } from "./runners/performance.js"; +import { runStandards } from "./runners/standards.js"; +import { computeTrend } from "./trend.js"; +import type { CheckResult, Issue, StackInfo, VibeReport } from "./types.js"; + +const dirs: string[] = []; +afterEach(() => { + setGlobalSrcRoots(undefined); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "vcqa-fp2-")); + dirs.push(dir); + writeFileSync(join(dir, "package.json"), "{}"); + write(dir, files); + return dir; +} + +function write(dir: string, files: Record): void { + for (const [name, content] of Object.entries(files)) { + const full = join(dir, name); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } +} + +const TS_STACK: StackInfo = { + language: "typescript", + framework: "none", + bundler: "none", + testRunner: "none", + linter: "none", + packageManager: "npm", +}; + +/** The one issue of `rule`, fingerprinted as core.ts does. */ +function fingerprinted(result: CheckResult, rule: string): FingerprintedIssue { + const all = withIssueFingerprints(result.name, result.issues); + const hits = all.filter((i) => i.rule === rule); + expect(hits).toHaveLength(1); + return hits[0]; +} + +/** Run a check twice around an edit that only moves the measurement. */ +async function measuredRuleKeepsIdentity( + rule: string, + before: Record, + after: Record, + run: (dir: string) => CheckResult | Promise, +): Promise { + const dir = project(before); + const a = fingerprinted(await run(dir), rule); + write(dir, after); + const b = fingerprinted(await run(dir), rule); + expect(b.message).not.toBe(a.message); // the number really moved… + expect(fingerprintIssue("x", b)).not.toBe(fingerprintIssue("x", a)); // …which re-identified it in v1 + expect(b.fingerprint).toBe(a.fingerprint); // …and does not in v2 +} + +const lines = (n: number, f: (i: number) => string) => Array.from({ length: n }, (_, i) => f(i)).join("\n"); + +describe("v2 subjects: a changed measurement keeps the fingerprint", () => { + it("standards large-file", async () => { + await measuredRuleKeepsIdentity( + "large-file", + { "src/big.ts": lines(350, (i) => `export const v${i} = ${i};`) }, + { "src/big.ts": lines(380, (i) => `export const v${i} = ${i};`) }, + (dir) => runStandards(dir, TS_STACK), + ); + }); + + it("context high-token-count", async () => { + const pad = (i: number) => `export const val${i} = ${i}; // some padding text here to increase token count`; + await measuredRuleKeepsIdentity("high-token-count", { "src/big.ts": lines(500, pad) }, { "src/big.ts": lines(560, pad) }, (dir) => + runContext(dir), + ); + }); + + it("architecture high-fan-out", async () => { + const mods = Object.fromEntries(Array.from({ length: 14 }, (_, i) => [`src/mod${i}.ts`, `export const m${i} = ${i};\n`])); + const hub = (n: number) => + `${lines(n, (i) => `import { m${i} } from "./mod${i}";`)}\nexport const all = [${Array.from({ length: n }, (_, i) => `m${i}`).join(", ")}];\n`; + await measuredRuleKeepsIdentity("high-fan-out", { ...mods, "src/hub.ts": hub(11) }, { "src/hub.ts": hub(13) }, (dir) => + runArchitecture(dir), + ); + }); + + it("performance barrel-import", async () => { + const barrel = (n: number) => lines(n, (i) => `export * from "./mod${i}";`); + const mods = Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`src/lib/mod${i}.ts`, `export const m${i} = ${i};\n`])); + await measuredRuleKeepsIdentity("barrel-import", { ...mods, "src/lib/index.ts": barrel(3) }, { "src/lib/index.ts": barrel(5) }, (dir) => + runPerformance(dir), + ); + }); + + it("complexity long-function (keyed by function name)", async () => { + const fn = (n: number) => `export function AdminLayout() {\n${lines(n, (i) => ` const x${i} = ${i};`)}\n return 0;\n}\n`; + await measuredRuleKeepsIdentity("long-function", { "src/admin.ts": fn(65) }, { "src/admin.ts": fn(80) }, (dir) => runComplexity(dir)); + }); + + it("complexity: two long functions in one file stay distinct", async () => { + const fn = (name: string) => `export function ${name}() {\n${lines(70, (i) => ` const x${i} = ${i};`)}\n return 0;\n}\n`; + const dir = project({ "src/two.ts": `${fn("First")}\n${fn("Second")}` }); + const issues = withIssueFingerprints("complexity", runComplexity(dir).issues).filter((i) => i.rule === "long-function"); + expect(issues).toHaveLength(2); + expect(issues[0].fingerprint).not.toBe(issues[1].fingerprint); + }); +}); + +describe("v2 duplication identity", () => { + const block = [ + "function processUser(user: User) {", + " const name = user.firstName + ' ' + user.lastName;", + " const email = user.email.toLowerCase().trim();", + " const age = calculateAge(user.birthDate);", + " const isActive = user.status === 'active';", + " const role = user.permissions.includes('admin') ? 'admin' : 'user';", + " return { name, email, age, isActive, role };", + "}", + ].join("\n"); + + it("inserting lines above both clones keeps the fingerprint", async () => { + const dir = project({ "src/a.ts": `${block}\n`, "src/b.ts": `${block}\n` }); + const before = fingerprinted(await runDuplication(dir), "duplicate-code"); + // Padding that cannot extend the clone: the token just before the block + // differs (";" vs "}"), so the maximal clone stays the block itself. + const padA = lines(5, (i) => `export const alpha${i} = "${"a".repeat(i + 1)}";`); + const padB = lines(9, (i) => `if (Math.random() > ${i}) { console.log("b${i}"); }`); + write(dir, { "src/a.ts": `${padA}\n${block}\n`, "src/b.ts": `${padB}\n${block}\n` }); + const after = fingerprinted(await runDuplication(dir), "duplicate-code"); + expect(after.file).not.toBe(before.file); // the :line moved + expect(after.fingerprint).toBe(before.fingerprint); + }); +}); + +describe("v2 content anchors for repeated findings", () => { + const readerFor = + (files: Record): SourceLineReader => + (file, line) => + files[file]?.split("\n")[line - 1]; + + /** One finding per `as any` line, all with the same message — as type-safety emits them. */ + function anyFindings(src: string): Issue[] { + return src + .split("\n") + .flatMap((text, i) => + text.includes("as any") + ? [{ severity: "warning" as const, rule: "as-any", message: "Avoid `as any`", file: "src/a.ts", line: i + 1 }] + : [], + ); + } + + function fps(src: string): string[] { + return withIssueFingerprints("type-safety", anyFindings(src), readerFor({ "src/a.ts": src })).map((i) => i.fingerprint!); + } + + const one = "const a = x as any;"; + const two = "const b = y as any;"; + const three = "const c = z as any;"; + + it("two identical findings on different lines get distinct fingerprints", () => { + const [a, b] = fps(`${one}\n${two}`); + expect(a).not.toBe(b); + // In v1 they collided. + const [v1a, v1b] = anyFindings(`${one}\n${two}`).map((i) => fingerprintIssue("type-safety", i)); + expect(v1a).toBe(v1b); + }); + + it("inserting a third identical finding above them leaves the first two unchanged", () => { + const [a, b] = fps(`${one}\n${two}`); + const after = fps(`${three}\n\n${one}\n${two}`); + expect(after).toHaveLength(3); + expect(after.slice(1)).toEqual([a, b]); + expect(new Set(after).size).toBe(3); + }); + + it("identical source lines are told apart by an ordinal, and the first keeps its fingerprint", () => { + const [solo] = fps(one); + const [a, b] = fps(`${one}\n${one}`); + expect(a).not.toBe(b); + expect(a).toBe(solo); + }); + + it("issues without a line get an ordinal by emission order", () => { + const iss = { severity: "warning" as const, rule: "r", message: "same" }; + const out = withIssueFingerprints("c", [iss, iss, iss]).map((i) => i.fingerprint); + expect(new Set(out).size).toBe(3); + expect(out[0]).toBe(fingerprintIssue("c", iss)); // a lone, unanchored finding keeps its v1 key + }); + + it("fixing one of two identical findings reports exactly that one fixed", () => { + const src = `${one}\n${two}`; + const fixedSrc = `const a = x as unknown;\n${two}`; + const before = v2Report([check("type-safety", withIssueFingerprints("type-safety", anyFindings(src), readerFor({ "src/a.ts": src })))]); + const after = v2Report([ + check("type-safety", withIssueFingerprints("type-safety", anyFindings(fixedSrc), readerFor({ "src/a.ts": fixedSrc }))), + ]); + + const delta = computeDelta(before, after); + expect(delta.fixed).toHaveLength(1); + expect(delta.introduced).toHaveLength(0); + expect(delta.fixed[0]).toMatchObject({ line: 1 }); + + const trend = trendBetween(before, after); + expect(trend.fixedIssues).toBe(1); + expect(trend.newIssues).toBe(0); + expect(trend.fixed?.[0]).toMatchObject({ line: 1 }); + }); +}); + +describe("dependencies counts", () => { + it("a vulnerability count change still reports a change", () => { + const a = withIssueFingerprints("dependencies", [{ severity: "error", message: "95 high vulnerabilities" }]); + const b = withIssueFingerprints("dependencies", [{ severity: "error", message: "96 high vulnerabilities" }]); + expect(a[0].fingerprint).not.toBe(b[0].fingerprint); + + const delta = computeDelta(v2Report([check("dependencies", a)]), v2Report([check("dependencies", b)])); + expect(delta.introduced).toHaveLength(1); + expect(delta.fixed).toHaveLength(1); + }); +}); + +describe("v1/v2 boundary", () => { + const src = "const a = x as any;\nconst a = x as any;\nconst b = y as any;"; + const raw: { check: string; issues: Issue[] }[] = [ + { + check: "type-safety", + issues: [1, 2, 3].map((line) => ({ + severity: "warning" as const, + rule: "as-any", + message: "Avoid `as any`", + file: "src/a.ts", + line, + })), + }, + { + check: "standards", + issues: [{ severity: "warning", rule: "large-file", message: "350 lines — consider splitting", file: "src/a.ts" }], + }, + { check: "dependencies", issues: [{ severity: "error", message: "95 high vulnerabilities" }] }, + ]; + const reader: SourceLineReader = (file, line) => (file === "src/a.ts" ? src.split("\n")[line - 1] : undefined); + + function v1(): VibeReport { + const r = report( + raw.map((c) => + check( + c.check, + c.issues.map((i) => ({ ...i, fingerprint: fingerprintIssue(c.check, i) })), + ), + ), + ); + expect(r.meta.fingerprintVersion).toBeUndefined(); + return r; + } + function v2(): VibeReport { + const withSubject = raw.map((c) => ({ + ...c, + issues: c.issues.map((i) => (i.rule === "large-file" ? { ...i, subject: i.file } : i)), + })); + return v2Report(withSubject.map((c) => check(c.check, withIssueFingerprints(c.check, c.issues, reader)))); + } + + it("the stored fingerprints really differ across versions", () => { + const a = v1().checks.flatMap((c) => c.issues.map((i) => (i as FingerprintedIssue).fingerprint)); + const b = v2().checks.flatMap((c) => c.issues.map((i) => (i as FingerprintedIssue).fingerprint)); + expect(b.filter((fp) => !a.includes(fp)).length).toBeGreaterThan(0); + }); + + it("delta between a v1 and a v2 report of unchanged code reports 0 new / 0 fixed, both directions", () => { + for (const [before, after] of [ + [v1(), v2()], + [v2(), v1()], + ]) { + const delta = computeDelta(before, after); + expect(delta.fixed).toHaveLength(0); + expect(delta.introduced).toHaveLength(0); + } + }); + + it("trend between a v1 and a v2 report of unchanged code reports 0 new / 0 fixed", () => { + const trend = trendBetween(v1(), v2()); + expect(trend.newIssues).toBe(0); + expect(trend.fixedIssues).toBe(0); + }); +}); + +describe("scan writes v2", () => { + it("sets meta.fingerprintVersion and anchors repeated findings to their source lines", async () => { + const dir = project({ "src/a.ts": "export const a = x as any;\nexport const b = y as any;\n" }); + const result = await scan(dir, { checks: ["type-safety"], skipTests: true }); + expect(result.meta.fingerprintVersion).toBe(FINGERPRINT_VERSION); + const issues = result.checks.find((c) => c.name === "type-safety")!.issues as FingerprintedIssue[]; + const anys = issues.filter((i) => i.file === "src/a.ts"); + expect(anys.length).toBeGreaterThanOrEqual(2); + expect(new Set(anys.map((i) => i.fingerprint)).size).toBe(anys.length); + }); +}); + +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..299823b 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,71 @@ 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 (not the + * SFC-extracted script), so a reported line number means the same line a + * reader sees. Files are read at most once per 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 +119,63 @@ 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; +} + +function contentAnchor(issue: Issue, readLine?: SourceLineReader): string | undefined { + if (!readLine || !issue.file || typeof issue.line !== "number" || issue.line < 1) return undefined; + const text = readLine(normalizePath(issue.file), issue.line); + if (text === undefined) return undefined; + return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex").slice(0, 12); +} + +function hash(parts: string[]): string { + return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 16); +} + function normalizeMessage(message: string): string { return message.replace(/\s+/g, " ").trim().toLowerCase(); } diff --git a/src/runners/architecture.ts b/src/runners/architecture.ts index 6b40d29..ae6d4ee 100644 --- a/src/runners/architecture.ts +++ b/src/runners/architecture.ts @@ -16,7 +16,8 @@ import { cruise } from "dependency-cruiser"; import type { FileInventory } from "../file-inventory.js"; import { inventorySourceFiles } from "../file-inventory.js"; import { getProductionFiles, type SourceFile } from "../fs-utils.js"; -import type { CheckResult, Issue, WorkspaceInfo } from "../types.js"; +import { type FingerprintedIssue, normalizePath } from "../issue-fingerprint.js"; +import type { CheckResult, WorkspaceInfo } from "../types.js"; import { gradeFromScore } from "../types.js"; import { generateContainerDiagram } from "./diagrams.js"; @@ -45,7 +46,7 @@ export interface ArchGraph { export async function runArchitecture(cwd: string, workspace?: WorkspaceInfo, inventory?: FileInventory): Promise { const start = Date.now(); - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; const files = inventory ? inventorySourceFiles(inventory) : getProductionFiles(cwd); if (files.length < 2) { @@ -161,6 +162,8 @@ export async function runArchitecture(cwd: string, workspace?: WorkspaceInfo, in message: `High fan-out: imports ${node.imports.length} modules — hard to test in isolation`, file: path, rule: "high-fan-out", + // Identity is the file, not its import count (#97). + subject: normalizePath(path), }); } } diff --git a/src/runners/complexity.ts b/src/runners/complexity.ts index 0f2f720..2259ebd 100644 --- a/src/runners/complexity.ts +++ b/src/runners/complexity.ts @@ -3,7 +3,8 @@ import type { FileInventory } from "../file-inventory.js"; import { inventorySourceFiles } from "../file-inventory.js"; import { getProductionFiles } from "../fs-utils.js"; -import type { CheckResult, Issue } from "../types.js"; +import { type FingerprintedIssue, normalizePath } from "../issue-fingerprint.js"; +import type { CheckResult } from "../types.js"; import { gradeFromScore } from "../types.js"; interface FunctionMetric { @@ -19,7 +20,7 @@ const MAX_COMPLEXITY = 15; export function runComplexity(cwd: string, inventory?: FileInventory): CheckResult { const start = Date.now(); - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; const functions: FunctionMetric[] = []; const sourceFiles = inventory ? inventorySourceFiles(inventory) : getProductionFiles(cwd); @@ -44,6 +45,8 @@ export function runComplexity(cwd: string, inventory?: FileInventory): CheckResu file: f.file, line: f.startLine, rule: "long-function", + // Identity is the function, not its length (#97). + subject: `${normalizePath(f.file)}#${f.name}`, }); } if (f.complexity > MAX_COMPLEXITY) { @@ -54,6 +57,7 @@ export function runComplexity(cwd: string, inventory?: FileInventory): CheckResu file: f.file, line: f.startLine, rule: "high-complexity", + subject: `${normalizePath(f.file)}#${f.name}`, }); } } diff --git a/src/runners/context.ts b/src/runners/context.ts index 197d6f9..ded97a1 100644 --- a/src/runners/context.ts +++ b/src/runners/context.ts @@ -15,7 +15,8 @@ import type { FileInventory } from "../file-inventory.js"; import { inventorySourceFiles } from "../file-inventory.js"; import { getProductionFiles } from "../fs-utils.js"; -import type { CheckResult, Issue } from "../types.js"; +import { type FingerprintedIssue, normalizePath } from "../issue-fingerprint.js"; +import type { CheckResult } from "../types.js"; import { gradeFromScore } from "../types.js"; const MAX_FILE_TOKENS = 4000; // ~400 lines; beyond this LLMs lose mid-context info @@ -24,7 +25,7 @@ const CHARS_PER_TOKEN = 3.5; // empirical average for code export function runContext(cwd: string, inventory?: FileInventory): CheckResult { const start = Date.now(); - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; // Collect source files with imports const sourceFiles = inventory ? inventorySourceFiles(inventory) : getProductionFiles(cwd); @@ -62,6 +63,8 @@ export function runContext(cwd: string, inventory?: FileInventory): CheckResult message: `~${f.tokens} tokens (>${MAX_FILE_TOKENS}) — large context cost for LLMs`, file: f.path, rule: "high-token-count", + // Identity is the file, not its token count (#97). + subject: normalizePath(f.path), }); } } diff --git a/src/runners/duplication.ts b/src/runners/duplication.ts index a4c22bb..d53ed6b 100644 --- a/src/runners/duplication.ts +++ b/src/runners/duplication.ts @@ -25,7 +25,8 @@ import { } from "@jscpd/core"; import { type FileInventory, inventorySourceFiles } from "../file-inventory.js"; import { getProductionFiles, readDeps, type SourceFile } from "../fs-utils.js"; -import type { CheckResult, Issue } from "../types.js"; +import { type FingerprintedIssue, normalizePath, snippetDigest } from "../issue-fingerprint.js"; +import type { CheckResult } from "../types.js"; import { gradeFromScore } from "../types.js"; import { run } from "./exec.js"; @@ -76,12 +77,13 @@ export async function runDuplication(cwd: string, inventory?: FileInventory): Pr // Rank biggest clones first; stable tie-break for deterministic output. clones.sort((a, b) => b.lines - a.lines || a.fileA.localeCompare(b.fileA) || a.lineA - b.lineA); - const issues: Issue[] = clones.slice(0, MAX_ISSUES).map((c) => ({ + const issues: FingerprintedIssue[] = clones.slice(0, MAX_ISSUES).map((c) => ({ severity: "warning", message: `Duplicate (${c.lines} lines): ${c.snippet.slice(0, 100)}`, file: `${c.fileA}:${c.lineA} ↔ ${c.fileB}:${c.lineB}`, rule: "duplicate-code", snippet: c.snippet, + subject: cloneSubject(c.fileA, c.fileB, c.snippet), })); return { @@ -320,6 +322,16 @@ function scoreFromPct(dupPct: number): number { return Math.max(10, Math.round(30 - (dupPct - 50))); } +/** + * Clone identity (#97): the two paths without their `:line` (an edit above + * either clone must not re-identify it), sorted so A↔B equals B↔A, plus a + * hash of the normalised snippet so two clones between the same files differ. + */ +export function cloneSubject(fileA: string, fileB: string, snippet?: string): string { + const pair = [normalizePath(fileA), normalizePath(fileB)].sort().join(" ↔ "); + return snippet ? `${pair}#${snippetDigest(snippet)}` : pair; +} + function tryJscpd(cwd: string): CheckResult | null { // jscpd writes JSON to a file, not stdout. Use a temp output dir. const tmpDir = join(cwd, ".vibe-check", "jscpd-tmp"); @@ -348,7 +360,7 @@ function tryJscpd(cwd: string): CheckResult | null { const data = JSON.parse(rawData); if (!data.statistics) return null; - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; const clones = data.duplicates || []; for (const d of clones.slice(0, MAX_ISSUES)) { const fileA = d.firstFile?.name || "?"; @@ -359,6 +371,7 @@ function tryJscpd(cwd: string): CheckResult | null { message: `Duplicate (${lines} lines)`, file: `${fileA}:${d.firstFile?.start} ↔ ${fileB}:${d.secondFile?.start}`, rule: "duplicate-code", + subject: cloneSubject(fileA, fileB, typeof d.fragment === "string" ? d.fragment : undefined), }); } diff --git a/src/runners/performance.ts b/src/runners/performance.ts index c760fb7..83b94f0 100644 --- a/src/runners/performance.ts +++ b/src/runners/performance.ts @@ -12,7 +12,8 @@ import { isAbsolute, join } from "node:path"; import type { FileInventory, InventoryFile } from "../file-inventory.js"; import { inventoryFiles, inventorySourceFiles, readInventoryText } from "../file-inventory.js"; import { getProductionFiles, isIgnoredPath, normalizeToolPath, readDeps } from "../fs-utils.js"; -import type { CheckResult, Issue, WorkspaceInfo } from "../types.js"; +import { type FingerprintedIssue, normalizePath } from "../issue-fingerprint.js"; +import type { CheckResult, WorkspaceInfo } from "../types.js"; import { gradeFromScore } from "../types.js"; import { run } from "./exec.js"; @@ -37,7 +38,7 @@ const HEAVY_DEPS: Record = { export function runPerformance(cwd: string, workspace?: WorkspaceInfo, inventory?: FileInventory): CheckResult { const start = Date.now(); - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; const sourceFiles = inventory ? inventorySourceFiles(inventory) : getProductionFiles(cwd); if (sourceFiles.length === 0) { @@ -70,6 +71,8 @@ export function runPerformance(cwd: string, workspace?: WorkspaceInfo, inventory message: `Barrel file with ${exportLines.length} re-exports — defeats tree-shaking in many bundlers`, file: f.path, rule: "barrel-import", + // Identity is the file, not its re-export count (#97). + subject: normalizePath(f.path), }); } } diff --git a/src/runners/standards.ts b/src/runners/standards.ts index 6dde851..ec58413 100644 --- a/src/runners/standards.ts +++ b/src/runners/standards.ts @@ -14,7 +14,8 @@ import { basename, extname, join } from "node:path"; import type { FileInventory } from "../file-inventory.js"; import { inventorySourceFiles } from "../file-inventory.js"; import { getProductionFiles } from "../fs-utils.js"; -import type { CheckResult, Issue, StackInfo, WorkspaceInfo } from "../types.js"; +import { type FingerprintedIssue, normalizePath } from "../issue-fingerprint.js"; +import type { CheckResult, StackInfo, WorkspaceInfo } from "../types.js"; import { gradeFromScore } from "../types.js"; interface PatternCheck { @@ -53,7 +54,7 @@ const CODE_SMELLS: PatternCheck[] = [ export function runStandards(cwd: string, stack: StackInfo, workspace?: WorkspaceInfo, inventory?: FileInventory): CheckResult { const start = Date.now(); - const issues: Issue[] = []; + const issues: FingerprintedIssue[] = []; // Detect CLI projects — console.log is intentional in CLI tools let isCLI = false; @@ -129,6 +130,8 @@ export function runStandards(cwd: string, stack: StackInfo, workspace?: Workspac message: `${lines} lines — split this file (exponential penalty above ${SOFT_LIMIT})`, file: f.path, rule: "large-file", + // Identity is the file, not its line count (#97). + subject: normalizePath(f.path), }); } else if (lines > SOFT_LIMIT) { largeFiles++; @@ -139,6 +142,7 @@ export function runStandards(cwd: string, stack: StackInfo, workspace?: Workspac message: `${lines} lines — consider splitting (penalty grows exponentially above ${SOFT_LIMIT})`, file: f.path, rule: "large-file", + subject: normalizePath(f.path), }); } } diff --git a/src/trend.ts b/src/trend.ts index 15f7066..7baeed2 100644 --- a/src/trend.ts +++ b/src/trend.ts @@ -2,8 +2,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { type IssueSnapshot, issueSnapshot, readIssueFingerprint } from "./issue-fingerprint.js"; -import type { VibeReport } from "./types.js"; +import { comparableFingerprints, type IssueSnapshot, issueSnapshot } from "./issue-fingerprint.js"; +import type { Issue, VibeReport } from "./types.js"; export interface TrendDelta { scoreDelta: number; // positive = improved @@ -42,8 +42,10 @@ export function computeTrend(report: VibeReport, outputDir: string): TrendDelta }); } - const prevIssues = issueMultiset(prev); - const currIssues = issueMultiset(report); + // 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 introduced = surplus(currIssues, prevIssues); const fixed = surplus(prevIssues, currIssues); const newIssues = introduced.length; @@ -53,11 +55,11 @@ export function computeTrend(report: VibeReport, outputDir: string): TrendDelta } /** Group issues by fingerprint, keeping every occurrence (a multiset, as in delta.ts). */ -function issueMultiset(report: VibeReport): Map { +function issueMultiset(report: VibeReport, keyOf: (checkName: string, issue: Issue) => string): Map { const out = new Map(); for (const check of report.checks) { for (const issue of check.issues) { - const fp = readIssueFingerprint(check.name, issue); + const fp = keyOf(check.name, issue); const list = out.get(fp); const snap = issueSnapshot(check.name, issue); if (list) list.push(snap); diff --git a/src/types.ts b/src/types.ts index fce8213..1fa99cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,5 +82,7 @@ export interface VibeReport extends Omit { scanPolicy?: Record; fileInventory?: Record; analyzerSnapshots?: AnalyzerSnapshot[]; + /** Issue fingerprint scheme (#97); absent = 1. Declared by schema 0.6.0. */ + fingerprintVersion?: number; }; } From 9588bb7f68a1a684d20586f8d9919a156471ff4e Mon Sep 17 00:00:00 2001 From: Sergey Ivochkin Date: Mon, 5 Oct 2026 19:28:01 +1100 Subject: [PATCH 2/7] test(fingerprint): stop the barrel-import test spawning Knip (#97) runPerformance shells out to `npx knip` per root, which took the barrel-import identity test to 1-10 s and past the 5 s default timeout on a loaded machine. Knip plays no part in barrel detection, so the test file stubs only that command; the test now runs in ~10 ms. The scan-backed describe also gets an explicit 30 s timeout for slow CI runners. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SmtSK7qDTyXhimJ9grap88 --- src/fingerprint-v2.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/fingerprint-v2.test.ts b/src/fingerprint-v2.test.ts index c4bd366..8a14958 100644 --- a/src/fingerprint-v2.test.ts +++ b/src/fingerprint-v2.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { scan } from "./core.js"; import { computeDelta } from "./delta.js"; import { setGlobalSrcRoots } from "./fs-utils.js"; @@ -23,6 +23,16 @@ import { runStandards } from "./runners/standards.js"; import { computeTrend } from "./trend.js"; import type { CheckResult, Issue, StackInfo, VibeReport } from "./types.js"; +// runPerformance shells out to `npx knip` once per root, which dominates its +// runtime and plays no part in barrel detection. Stub only that command. +vi.mock("./runners/exec.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + run: (...args: Parameters) => (args[0].includes("knip") ? { stdout: "", ok: true } : actual.run(...args)), + }; +}); + const dirs: string[] = []; afterEach(() => { setGlobalSrcRoots(undefined); @@ -80,7 +90,8 @@ async function measuredRuleKeepsIdentity( const lines = (n: number, f: (i: number) => string) => Array.from({ length: n }, (_, i) => f(i)).join("\n"); -describe("v2 subjects: a changed measurement keeps the fingerprint", () => { +// These run real checks over temp projects; give a slow CI runner headroom. +describe("v2 subjects: a changed measurement keeps the fingerprint", { timeout: 30_000 }, () => { it("standards large-file", async () => { await measuredRuleKeepsIdentity( "large-file", From ff86b43590327be06bd1f82d55f0e39758f4fa6d Mon Sep 17 00:00:00 2001 From: Sergey Ivochkin Date: Mon, 5 Oct 2026 19:29:45 +1100 Subject: [PATCH 3/7] fix(fingerprint): do not content-anchor Vue/Svelte findings (#97) Runners analyse SFCs through 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 }; } diff --git a/src/issue-fingerprint.ts b/src/issue-fingerprint.ts index 299823b..0944afd 100644 --- a/src/issue-fingerprint.ts +++ b/src/issue-fingerprint.ts @@ -44,9 +44,12 @@ export interface IssueSnapshot { export type SourceLineReader = (file: string, line: number) => string | undefined; /** - * Line reader over the scan's FileInventory. Reads the raw file (not the - * SFC-extracted script), so a reported line number means the same line a - * reader sees. Files are read at most once per scan. + * 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])); @@ -165,8 +168,21 @@ function v2Key(checkName: string, issue: FingerprintedIssue, readLine?: SourceLi 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 `