Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Added a release-prep checklist for auditing changelog, package metadata, and dry-run package contents without publishing.
- Improved bounded source grouping so large flat directories split repeated filename families like command, plugin, doctor, and runtime files into more coherent review slices.
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
- Fixed finding signatures so equivalent evidence remains stable across re-reviews, thanks @rohitjavvadi.
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.
Expand Down
127 changes: 127 additions & 0 deletions src/findings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { findingFromOutput } from "./findings.js";
import type { ReviewOutput } from "./types.js";

describe("findingFromOutput", () => {
it("keeps signatures stable for equivalent evidence with different key insertion order", () => {
const orderedEvidence = {
path: "src/app.ts",
startLine: 10,
endLine: 12,
symbol: "runWorkflow",
quote: "await runWorkflow();",
};
const reorderedEvidence = {} as Record<string, unknown>;
reorderedEvidence["quote"] = "await runWorkflow();";
reorderedEvidence["symbol"] = "runWorkflow";
reorderedEvidence["endLine"] = 12;
reorderedEvidence["startLine"] = 10;
reorderedEvidence["path"] = "src/app.ts";

const first = findingFromOutput(finding([orderedEvidence]), "feature_1", "run_1");
const second = findingFromOutput(
finding([reorderedEvidence as ReviewOutput["findings"][number]["evidence"][number]]),
"feature_1",
"run_1",
);

expect(second.signature).toBe(first.signature);
expect(second.findingId).toBe(first.findingId);
});

it("keeps signatures stable for equivalent evidence with different reference order", () => {
const first = findingFromOutput(
finding([
{
path: "src/app.ts",
startLine: 10,
endLine: 12,
symbol: "runWorkflow",
quote: "await runWorkflow();",
},
{
path: "src/provider.ts",
startLine: 20,
endLine: 22,
symbol: null,
quote: null,
},
]),
"feature_1",
"run_1",
);
const second = findingFromOutput(
finding([
{
path: "src/provider.ts",
startLine: 20,
endLine: 22,
symbol: null,
quote: null,
},
{
path: "src/app.ts",
startLine: 10,
endLine: 12,
symbol: "runWorkflow",
quote: "await runWorkflow();",
},
]),
"feature_1",
"run_1",
);

expect(second.signature).toBe(first.signature);
expect(second.findingId).toBe(first.findingId);
});

it("keeps signatures distinct when evidence content changes", () => {
const first = findingFromOutput(
finding([
{
path: "src/app.ts",
startLine: 10,
endLine: 12,
symbol: "runWorkflow",
quote: "await runWorkflow();",
},
]),
"feature_1",
"run_1",
);
const second = findingFromOutput(
finding([
{
path: "src/app.ts",
startLine: 10,
endLine: 12,
symbol: "runWorkflow",
quote: "await runWorkflowSafely();",
},
]),
"feature_1",
"run_1",
);

expect(second.signature).not.toBe(first.signature);
expect(second.findingId).not.toBe(first.findingId);
});
});

function finding(
evidence: ReviewOutput["findings"][number]["evidence"],
): ReviewOutput["findings"][number] {
return {
title: "Finding title",
category: "bug",
severity: "medium",
confidence: "high",
evidence,
reasoning: "Reasoning.",
reproduction: null,
recommendation: "Recommendation.",
whyTestsDoNotAlreadyCoverThis: "No regression coverage exists.",
suggestedRegressionTest: null,
minimumFixScope: "Keep the fix narrow.",
};
}
66 changes: 65 additions & 1 deletion src/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function findingFromOutput(
featureId,
finding.category,
finding.title,
JSON.stringify(finding.evidence),
canonicalEvidence(finding.evidence),
]);
const now = nowIso();
return {
Expand Down Expand Up @@ -78,3 +78,67 @@ export function findingFromOutput(
updatedAt: now,
};
}

type CanonicalEvidenceRef = {
path: string;
startLine: number | null;
endLine: number | null;
symbol: string | null;
quote: string | null;
};

function canonicalEvidence(finding: ReviewOutput["findings"][number]["evidence"]): string {
return JSON.stringify(
finding
.map(
(evidence): CanonicalEvidenceRef => ({
path: evidence.path,
startLine: evidence.startLine,
endLine: evidence.endLine,
symbol: evidence.symbol,
quote: evidence.quote,
}),
)
.toSorted(compareCanonicalEvidence),
);
}

function compareCanonicalEvidence(left: CanonicalEvidenceRef, right: CanonicalEvidenceRef): number {
return (
compareStrings(left.path, right.path) ||
compareNullableNumbers(left.startLine, right.startLine) ||
compareNullableNumbers(left.endLine, right.endLine) ||
compareNullableStrings(left.symbol, right.symbol) ||
compareNullableStrings(left.quote, right.quote)
);
}

function compareNullableNumbers(left: number | null, right: number | null): number {
if (left === right) {
return 0;
}
if (left === null) {
return -1;
}
if (right === null) {
return 1;
}
return left - right;
}

function compareNullableStrings(left: string | null, right: string | null): number {
if (left === right) {
return 0;
}
if (left === null) {
return -1;
}
if (right === null) {
return 1;
}
return compareStrings(left, right);
}

function compareStrings(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}