Skip to content
Merged
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<script>` or to the raw file depending on the check — so
their repeats are told apart by line order alone, so a new repeat added
above existing ones may be reported as the last in line order.
- limit: findings on textually identical source lines can only be told
apart by position. Counts of new and fixed stay right, but when one is
added or removed above the others, the one reported is the last in line
order rather than the one that changed.
- **Changed**: `complexity` no longer mistakes top-level `if`/`for`/`while`/
`switch` blocks for functions. Their pseudo-function findings ("if: N
lines") disappear and `functionCount` drops — by about 5% on a real
repository.
- **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
Expand Down
10 changes: 6 additions & 4 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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";
Expand Down Expand Up @@ -126,6 +126,7 @@ export async function scan(cwd: string, options: ScanOptions = {}): Promise<Vibe
envIgnore: process.env.VCQA_IGNORE,
});
const fileInventory = buildFileInventory(resolvedCwd, workspace, scanPolicy);
const readSourceLine = inventoryLineReader(fileInventory);

setGlobalSrcRoots(workspace.isMonorepo ? workspace.srcRoots : undefined);
setGlobalIgnore(config.ignore);
Expand Down Expand Up @@ -277,7 +278,7 @@ export async function scan(cwd: string, options: ScanOptions = {}): Promise<Vibe
result.details = { ...result.details, toolRuns };
}

result = normalizeCheckResult(result);
result = normalizeCheckResult(result, readSourceLine);

// Apply per-check ignore patterns
const patterns = getCheckIgnore(config, result.name);
Expand Down Expand Up @@ -319,6 +320,7 @@ export async function scan(cwd: string, options: ScanOptions = {}): Promise<Vibe
scanPolicy: scanPolicySummary(scanPolicy),
fileInventory: fileInventory.summary,
analyzerSnapshots: buildAnalyzerSnapshots(checks),
fingerprintVersion: FINGERPRINT_VERSION,
repoUrl,
// Mirrors git.branch; "" when unknown — never a guessed "main".
branch: git.branch ?? "",
Expand All @@ -341,7 +343,7 @@ export async function scan(cwd: string, options: ScanOptions = {}): Promise<Vibe
};
}

function normalizeCheckResult(result: CheckResult): NormalizedCheckResult {
function normalizeCheckResult(result: CheckResult, readSourceLine?: SourceLineReader): NormalizedCheckResult {
const originalScore = result.score;
const issues = [...(Array.isArray(result.issues) ? result.issues : [])];
const details = { ...(result.details ?? {}) };
Expand Down Expand Up @@ -369,7 +371,7 @@ function normalizeCheckResult(result: CheckResult): NormalizedCheckResult {
score,
grade: gradeFromScore(score),
details: normalizedDetails,
issues: withIssueFingerprints(result.name, issues),
issues: withIssueFingerprints(result.name, issues, readSourceLine),
};
}

Expand Down
10 changes: 4 additions & 6 deletions src/delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* to display "what changed since last scan."
*/

import { readIssueFingerprint } from "./issue-fingerprint.js";
import { comparableFingerprints } from "./issue-fingerprint.js";
import type { Issue, VibeReport } from "./types.js";

export interface DeltaIssue {
Expand Down Expand Up @@ -35,11 +35,6 @@ export interface ScanDelta {
introduced: DeltaIssue[];
}

/** Fingerprint an issue for stable matching (ignores line numbers which shift after edits). */
function issueKey(check: string, iss: Issue): string {
return readIssueFingerprint(check, iss);
}

/** 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);
Expand All @@ -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);
Expand Down
Loading