diff --git a/review-enrichment/src/analyzers/verbatim-duplication.ts b/review-enrichment/src/analyzers/verbatim-duplication.ts new file mode 100644 index 0000000000..ee628e5e00 --- /dev/null +++ b/review-enrichment/src/analyzers/verbatim-duplication.ts @@ -0,0 +1,310 @@ +// Verbatim-duplication analyzer (#1520). Detects code added by the PR that is a near-verbatim +// copy of an existing block elsewhere in the repo — copy-paste instead of importing the helper. +// Uses winnowing k-gram fingerprinting (Schleimer et al. 2003, jscpd/MOSS-style): normalize the +// added hunks and the repo tree, fingerprint both, flag blocks where the PR's fingerprints are +// substantially contained in an existing file. Network: GitHub git-tree (recursive) at headSha +// then git-blob per same-language file. Pure compute after the fetches; fail-safe on any error. +import type { EnrichRequest, DuplicationFinding } from "../types.js"; + +// Tuning constants. k=8 character k-grams avoid common short keywords while fitting 5-line blocks. +// w=4 winnowing window keeps ~1 fingerprint per 4 k-grams (Θ(n/w) per document). +const K = 8; +const W = 4; +const CONTAINMENT_THRESHOLD = 0.65; // fraction of block fingerprints that must appear in source +const MIN_BLOCK_LINES = 5; // skip trivially-small added-line sequences +const MAX_BLOCK_CHARS = 4000; // skip huge auto-generated blocks (unlikely to be real copy-paste) +const MAX_REPO_FILES = 150; +const MAX_FILE_BYTES = 64 * 1024; // 64 KB per source file +const MAX_TOTAL_BYTES = 512 * 1024; // 512 KB total fetch budget +const MAX_FINDINGS = 10; +const CONCURRENT_FETCHES = 8; + +// Map file extension → language group. Only compare files within the same group. +const LANGUAGE_EXT: Record = { + ts: "ts", tsx: "ts", + js: "js", jsx: "js", mjs: "js", cjs: "js", + py: "py", + go: "go", + rs: "rs", + java: "java", + c: "c", h: "c", cpp: "c", cc: "c", cxx: "c", hpp: "c", + cs: "cs", + rb: "rb", + php: "php", + swift: "swift", + kt: "kotlin", +}; + +function langOf(path: string): string | null { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return LANGUAGE_EXT[ext] ?? null; +} + +/** Lowercase + collapse all whitespace to a single space. Sufficient for near-verbatim detection. */ +export function normalizeForFingerprint(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +/** Compute a winnowed fingerprint: a Set of minimum k-gram hashes, one per sliding window of width w. */ +export function computeFingerprint(text: string, k = K, w = W): Set { + const fps = new Set(); + const n = text.length; + if (n < k) return fps; + + // Polynomial rolling hash: h_i = sum_{j=0}^{k-1} text[i+j] * 31^(k-1-j) (mod 2^32) + const hashes: number[] = []; + for (let i = 0; i + k <= n; i++) { + let h = 0; + for (let j = 0; j < k; j++) { + h = (Math.imul(h, 31) + text.charCodeAt(i + j)) | 0; + } + hashes.push(h >>> 0); + } + + // Winnow: for each window of width w pick the minimum hash. + const last = hashes.length; + if (last < w) { + // Fewer hashes than window size — add them all. + for (const h of hashes) fps.add(h); + } else { + for (let i = 0; i + w <= last; i++) { + let min = hashes[i]!; + for (let j = i + 1; j < i + w; j++) { + const hj = hashes[j]!; + if (hj < min) min = hj; + } + fps.add(min); + } + } + return fps; +} + +/** Fraction of blockFps hashes found in sourceFps (containment ≠ Jaccard; suited for block-in-file matching). */ +export function fingerprintContainment( + blockFps: Set, + sourceFps: Set, +): number { + if (blockFps.size === 0) return 0; + let matches = 0; + for (const h of blockFps) if (sourceFps.has(h)) matches++; + return matches / blockFps.size; +} + +export interface AddedBlock { + headFile: string; + headLine: number; // 1-indexed start line in the new file + text: string; // normalised text of the block + lineCount: number; +} + +/** Extract consecutive-added-line blocks (≥ MIN_BLOCK_LINES) from a unified diff patch. */ +export function extractAddedBlocks(path: string, patch: string): AddedBlock[] { + const blocks: AddedBlock[] = []; + let rawLines: string[] = []; + let blockStartLine = 0; + let newLine = 0; + + const flush = () => { + if (rawLines.length >= MIN_BLOCK_LINES) { + const text = normalizeForFingerprint(rawLines.join("\n")); + if (text.length > 0 && text.length <= MAX_BLOCK_CHARS) { + blocks.push({ headFile: path, headLine: blockStartLine, text, lineCount: rawLines.length }); + } + } + rawLines = []; + }; + + for (const line of patch.split("\n")) { + if (line.startsWith("+++") || line.startsWith("---")) continue; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + flush(); + newLine = Number(hunk[1]); + continue; + } + if (line.startsWith("+")) { + if (rawLines.length === 0) blockStartLine = newLine; + rawLines.push(line.slice(1)); + newLine++; + } else { + flush(); + if (!line.startsWith("-")) newLine++; // context line advances counter; removed lines don't + } + } + flush(); + return blocks; +} + +/** + * Slide a window of `blockLineCount` lines over already-normalised source lines and return the + * 1-indexed start line of the window with the highest fingerprint containment (≥ threshold) and + * that containment score, or null if no window meets the threshold. The window is slightly widened + * (+2 lines) to tolerate minor size mismatches between the PR block and the original. + */ +export function findBestSourceLine( + blockFps: Set, + blockLineCount: number, + sourceLinesNorm: string[], + threshold = CONTAINMENT_THRESHOLD, +): { line: number; containment: number } | null { + if (sourceLinesNorm.length < blockLineCount) return null; + const windowSize = blockLineCount + 2; + let bestLine = -1; + let bestContainment = 0; + + for (let i = 0; i + blockLineCount <= sourceLinesNorm.length; i++) { + const end = Math.min(i + windowSize, sourceLinesNorm.length); + const windowText = sourceLinesNorm.slice(i, end).join(" "); + const windowFps = computeFingerprint(windowText); + const c = fingerprintContainment(blockFps, windowFps); + if (c > bestContainment) { + bestContainment = c; + bestLine = i + 1; + } + } + return bestContainment >= threshold ? { line: bestLine, containment: bestContainment } : null; +} + +interface TreeEntry { + path: string; + type: string; + sha: string; + size?: number; +} + +interface BlobResponse { + content?: string; + encoding?: string; +} + +/** Analyzer entrypoint: fingerprint PR-added hunks against same-language files in the repo tree. */ +export async function scanVerbatimDuplication( + req: EnrichRequest, + fetchFn: typeof fetch, + opts?: { signal?: AbortSignal }, +): Promise { + const { repoFullName, headSha, githubToken, files = [] } = req; + + // Requires a short-lived broker token + headSha to fetch the git tree. + if (!githubToken || !headSha) return []; + + // Collect added blocks grouped by language. + const langBlocks = new Map(); + const prFiles = new Set(files.map((f) => f.path)); + + for (const file of files) { + if (!file.patch) continue; + const lang = langOf(file.path); + if (!lang) continue; + const blocks = extractAddedBlocks(file.path, file.patch); + if (blocks.length === 0) continue; + const existing = langBlocks.get(lang) ?? []; + existing.push(...blocks); + langBlocks.set(lang, existing); + } + + if (langBlocks.size === 0) return []; + + const parts = repoFullName.split("/"); + const owner = parts[0]; + const repo = parts[1]; + if (!owner || !repo) return []; + + const headers: Record = { + Authorization: `Bearer ${githubToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + // Fetch the recursive git tree to get all blob paths + SHAs. + let treeEntries: TreeEntry[]; + try { + const treeResp = await fetchFn( + `https://api.github.com/repos/${owner}/${repo}/git/trees/${headSha}?recursive=1`, + { headers, signal: opts?.signal }, + ); + if (!treeResp.ok) return []; + const treeJson = (await treeResp.json()) as { tree?: TreeEntry[] }; + treeEntries = treeJson.tree ?? []; + } catch { + return []; + } + + // Filter to blobs of the same language as the PR additions, excluding PR-modified files. + const langs = new Set(langBlocks.keys()); + const candidates = treeEntries + .filter((e) => e.type === "blob" && !prFiles.has(e.path) && langs.has(langOf(e.path) ?? "")) + .sort((a, b) => (a.size ?? 0) - (b.size ?? 0)) // prefer smaller files first + .slice(0, MAX_REPO_FILES); + + if (candidates.length === 0) return []; + + const findings: DuplicationFinding[] = []; + let totalBytesUsed = 0; + + // Fetch blobs in bounded-concurrency batches. + for ( + let batchStart = 0; + batchStart < candidates.length && findings.length < MAX_FINDINGS; + batchStart += CONCURRENT_FETCHES + ) { + if (totalBytesUsed >= MAX_TOTAL_BYTES) break; + const batch = candidates.slice(batchStart, batchStart + CONCURRENT_FETCHES); + + const settled = await Promise.allSettled( + batch.map(async (entry): Promise<{ path: string; text: string } | null> => { + if ((entry.size ?? 0) > MAX_FILE_BYTES) return null; + const blobResp = await fetchFn( + `https://api.github.com/repos/${owner}/${repo}/git/blobs/${entry.sha}`, + { headers, signal: opts?.signal }, + ); + if (!blobResp.ok) return null; + const blob = (await blobResp.json()) as BlobResponse; + if (blob.encoding !== "base64" || !blob.content) return null; + const decoded = Buffer.from(blob.content.replace(/\s/g, ""), "base64").toString("utf-8"); + if (decoded.length > MAX_FILE_BYTES) return null; + return { path: entry.path, text: decoded }; + }), + ); + + for (const result of settled) { + if (result.status !== "fulfilled" || !result.value) continue; + const { path: sourcePath, text: sourceText } = result.value; + if (totalBytesUsed + sourceText.length > MAX_TOTAL_BYTES) continue; + totalBytesUsed += sourceText.length; + + const lang = langOf(sourcePath); + if (!lang) continue; + const blocks = langBlocks.get(lang) ?? []; + + // Compute the whole-file fingerprint once per source file (cheap filter before window scan). + const sourceNorm = normalizeForFingerprint(sourceText); + const sourceFps = computeFingerprint(sourceNorm); + const sourceLinesNorm = sourceText.split("\n").map((l) => normalizeForFingerprint(l)); + + for (const block of blocks) { + if (findings.length >= MAX_FINDINGS) break; + const blockFps = computeFingerprint(block.text); + if (blockFps.size === 0) continue; + + // Phase 1: whole-file containment — fast gate before the per-window scan. + if (fingerprintContainment(blockFps, sourceFps) < CONTAINMENT_THRESHOLD) continue; + + // Phase 2: sliding-window scan to locate the best-matching region in the source. + const best = findBestSourceLine(blockFps, block.lineCount, sourceLinesNorm); + if (best === null) continue; + + findings.push({ + headFile: block.headFile, + headLine: block.headLine, + sourceFile: sourcePath, + sourceLine: best.line, + lineCount: block.lineCount, + similarity: Math.round(best.containment * 100) / 100, + }); + } + } + } + + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 4c3b483775..3408478fc4 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -14,6 +14,7 @@ import { scanInstallScripts } from "./analyzers/install-scripts.js"; import { scanActionPins } from "./analyzers/actions-pin.js"; import { scanEol } from "./analyzers/eol-check.js"; import { scanRedos } from "./analyzers/redos.js"; +import { scanVerbatimDuplication } from "./analyzers/verbatim-duplication.js"; import { scanCodeowners } from "./analyzers/codeowners.js"; import { renderBrief } from "./render.js"; @@ -28,6 +29,7 @@ const ANALYZERS: Record = { actionPin: (req) => scanActionPins(req), eol: (req) => scanEol(req), redos: (req) => scanRedos(req), + duplication: (req, signal) => scanVerbatimDuplication(req, fetch, { signal }), codeowners: (req, signal) => scanCodeowners(req, fetch, { signal }), }; diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 0d58263b76..20db3986b4 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -129,6 +129,16 @@ export function renderBrief( } } + const duplications = findings.duplication ?? []; + if (duplications.length) { + lines.push( + "### Near-verbatim code duplication (import the existing helper instead of copying)", + ); + for (const item of duplications) { + const pct = Math.round(item.similarity * 100); + lines.push( + `- ${safeCodeSpan(`${item.headFile}:${item.headLine}`)} duplicates ${safeCodeSpan(`${item.sourceFile}:${item.sourceLine}`)} (~${item.lineCount} lines, ${pct}% match) — refactor to import or extract a shared helper`, + ); const codeownersViolations = findings.codeowners ?? []; if (codeownersViolations.length) { const allOwners = new Set(codeownersViolations.flatMap((f) => f.owners)); diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 004443463d..195d8e117e 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -93,6 +93,15 @@ export interface RedosFinding { pattern: string; } +/** An added code block that is a near-verbatim copy of existing repo code (copy-paste instead of + * importing the helper). Cited as headFile:headLine vs sourceFile:sourceLine. */ +export interface DuplicationFinding { + headFile: string; + headLine: number; + sourceFile: string; + sourceLine: number; + lineCount: number; + similarity: number; /** A changed file governed by a CODEOWNERS rule where the PR author is not listed as an owner (#1515). * The blast radius (distinct ownership domains crossed) is derived at render time from the full findings set. */ export interface CodeownersFinding { @@ -109,6 +118,7 @@ export interface BriefFindings { installScript?: InstallScriptFinding[]; eol?: EolFinding[]; redos?: RedosFinding[]; + duplication?: DuplicationFinding[]; codeowners?: CodeownersFinding[]; } diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index 6f189d1143..9426efbf42 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -21,6 +21,14 @@ import { scanPatchForRedos, scanRedos, } from "../dist/analyzers/redos.js"; +import { + normalizeForFingerprint, + computeFingerprint, + fingerprintContainment, + extractAddedBlocks, + findBestSourceLine, + scanVerbatimDuplication, +} from "../dist/analyzers/verbatim-duplication.js"; const NOW = new Date("2026-06-26").getTime(); const eolFetch = @@ -937,3 +945,352 @@ test("buildBrief: eol analyzer runs (real now, 2023 cycle is past)", async () => globalThis.fetch = realFetch; } }); + +// ── Verbatim-duplication analyzer (#1520) ────────────────────────────────────── + +test("normalizeForFingerprint: lowercases and collapses whitespace", () => { + assert.equal(normalizeForFingerprint(" Hello\t\nWorld "), "hello world"); + assert.equal(normalizeForFingerprint("const X = 1;"), "const x = 1;"); + assert.equal(normalizeForFingerprint(""), ""); + // Consecutive whitespace including newlines → single space. + assert.equal(normalizeForFingerprint("a\n\n\tb"), "a b"); +}); + +test("computeFingerprint: returns a non-empty Set for text longer than k, empty for short text", () => { + // Fewer than k characters → empty fingerprint. + assert.equal(computeFingerprint("hi").size, 0); + // A longer string produces fingerprints. + const fps = computeFingerprint("function add(a, b) { return a + b; }"); + assert.ok(fps.size > 0); + // Identical text produces the same fingerprint. + const fps2 = computeFingerprint("function add(a, b) { return a + b; }"); + assert.deepEqual([...fps].sort(), [...fps2].sort()); + // Completely different text produces different fingerprints. + const fps3 = computeFingerprint("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + const intersection = [...fps].filter((h) => fps3.has(h)); + assert.ok(intersection.length < fps.size, "different text should share few fingerprints"); +}); + +test("fingerprintContainment: returns fraction of block fps in source fps", () => { + // Same text → 1.0 containment. + const t = "export function greet(name) { return hello + name; }"; + const fps = computeFingerprint(normalizeForFingerprint(t)); + assert.equal(fingerprintContainment(fps, fps), 1); + + // Disjoint sets → 0. + const aFps = new Set([1, 2, 3]); + const bFps = new Set([4, 5, 6]); + assert.equal(fingerprintContainment(aFps, bFps), 0); + + // Partial overlap. + const c = new Set([1, 2, 3, 4]); + const d = new Set([1, 2, 5, 6]); + assert.equal(fingerprintContainment(c, d), 0.5); + + // Empty block → 0 (not NaN). + assert.equal(fingerprintContainment(new Set(), fps), 0); +}); + +test("extractAddedBlocks: groups consecutive added lines into blocks, skips small blocks", () => { + const patch = [ + "@@ -1,3 +1,10 @@", + " context", + "+line one", + "+line two", + "+line three", + " context", + "+block2 line1", + "+block2 line2", + "+block2 line3", + "+block2 line4", + "+block2 line5", + ].join("\n"); + + const blocks = extractAddedBlocks("src/foo.ts", patch); + // First block has only 3 lines → below MIN_BLOCK_LINES=5, skipped. + // Second block has 5 lines → included. + assert.equal(blocks.length, 1); + assert.equal(blocks[0].headFile, "src/foo.ts"); + assert.equal(blocks[0].lineCount, 5); + assert.equal(blocks[0].headLine, 6); // block2 starts at new-file line 6 +}); + +test("extractAddedBlocks: hunk reset correctly assigns line numbers across multiple hunks", () => { + const patch = [ + "@@ -1,1 +1,6 @@", + "+a1", + "+a2", + "+a3", + "+a4", + "+a5", + "@@ -20,1 +26,5 @@", + "+b1", + "+b2", + "+b3", + "+b4", + "+b5", + ].join("\n"); + + const blocks = extractAddedBlocks("src/x.ts", patch); + assert.equal(blocks.length, 2); + assert.equal(blocks[0].headLine, 1); + assert.equal(blocks[1].headLine, 26); +}); + +test("findBestSourceLine: locates the window with highest containment, returns null below threshold", () => { + // Build a block whose normalised text appears verbatim in lines 4-8 of the source. + const blockLines = [ + "function sum(a, b) {", + " return a + b;", + "}", + "function mul(a, b) {", + " return a * b;", + ]; + const blockText = normalizeForFingerprint(blockLines.join("\n")); + const blockFps = computeFingerprint(blockText); + + const sourceLines = [ + "// header comment", + "const PI = 3.14;", + "const E = 2.71;", + "function sum(a, b) {", + " return a + b;", + "}", + "function mul(a, b) {", + " return a * b;", + "}", + "export { sum, mul };", + ]; + const sourceLinesNorm = sourceLines.map((l) => normalizeForFingerprint(l)); + + const result = findBestSourceLine(blockFps, blockLines.length, sourceLinesNorm); + // Any window from line 2-5 (1-indexed) fully contains the block; exact value depends on hashes. + assert.ok(result !== null, "should find a match"); + assert.ok(result.line >= 1 && result.line <= 6, `expected line in 1-6, got ${result.line}`); + assert.ok(result.containment >= 0.65, `expected containment ≥ 0.65, got ${result.containment}`); + + // Source shorter than block → null. + assert.equal(findBestSourceLine(blockFps, 100, sourceLinesNorm), null); +}); + +test("scanVerbatimDuplication: returns [] when githubToken or headSha is absent", async () => { + const neverFetch = async () => { throw new Error("should not fetch"); }; + assert.deepEqual( + await scanVerbatimDuplication({ repoFullName: "o/r", prNumber: 1 }, neverFetch), + [], + ); + assert.deepEqual( + await scanVerbatimDuplication({ repoFullName: "o/r", prNumber: 1, githubToken: "tok" }, neverFetch), + [], + ); + assert.deepEqual( + await scanVerbatimDuplication({ repoFullName: "o/r", prNumber: 1, headSha: "abc" }, neverFetch), + [], + ); +}); + +test("scanVerbatimDuplication: skips files with no patch or non-code extensions", async () => { + const neverFetch = async () => { throw new Error("should not fetch"); }; + // Only a README (no language match) and a patched file with no content → no blocks → no fetches. + const findings = await scanVerbatimDuplication( + { + repoFullName: "o/r", + prNumber: 1, + headSha: "abc", + githubToken: "tok", + files: [ + { path: "README.md", patch: "@@ -1,0 +1,1 @@\n+hello" }, + { path: "src/x.ts" }, // no patch + ], + }, + neverFetch, + ); + assert.deepEqual(findings, []); +}); + +test("scanVerbatimDuplication: returns [] on tree API failure, fail-safe", async () => { + const findings = await scanVerbatimDuplication( + { + repoFullName: "o/r", + prNumber: 1, + headSha: "abc", + githubToken: "tok", + files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,6 @@\n+a\n+b\n+c\n+d\n+e\n+f" }], + }, + async () => ({ ok: false, json: async () => ({}) }), + ); + assert.deepEqual(findings, []); +}); + +test("scanVerbatimDuplication: detects near-verbatim copy and reports headFile:headLine + sourceFile:sourceLine", async () => { + // Build a duplicated block: 8 added lines whose text also appears in a repo file. + const sharedCode = [ + "export function validate(input) {", + " if (!input) throw new Error('missing');", + " const trimmed = input.trim();", + " if (trimmed.length === 0) throw new Error('empty');", + " return trimmed.toLowerCase();", + "}", + "export function sanitize(value) {", + " return value.replace(/[^a-z0-9]/g, '');", + "}", + ].join("\n"); + + const prPatch = + "@@ -1,0 +5,9 @@\n" + + sharedCode + .split("\n") + .map((l) => `+${l}`) + .join("\n"); + + const sourceContent = [ + "// utilities", + "const VERSION = '1.0.0';", + "", + sharedCode, + "", + "export const NAME = 'util';", + ].join("\n"); + + // Mock: tree returns one same-language file; blob returns the source content. + const mockFetch = async (url: string) => { + if (String(url).includes("/git/trees/")) { + return { + ok: true, + json: async () => ({ + tree: [{ path: "src/utils.ts", type: "blob", sha: "blob1", size: sourceContent.length }], + }), + }; + } + if (String(url).includes("/git/blobs/")) { + const encoded = Buffer.from(sourceContent, "utf-8").toString("base64"); + return { ok: true, json: async () => ({ encoding: "base64", content: encoded }) }; + } + return { ok: false, json: async () => ({}) }; + }; + + const findings = await scanVerbatimDuplication( + { + repoFullName: "owner/repo", + prNumber: 42, + headSha: "deadbeef", + githubToken: "tok", + files: [{ path: "src/helpers.ts", patch: prPatch }], + }, + mockFetch as typeof fetch, + ); + + assert.equal(findings.length, 1); + assert.equal(findings[0].headFile, "src/helpers.ts"); + assert.equal(findings[0].headLine, 5); + assert.equal(findings[0].sourceFile, "src/utils.ts"); + assert.ok(findings[0].sourceLine > 0); + assert.equal(findings[0].lineCount, 9); + assert.ok(findings[0].similarity >= 0.65, `expected similarity ≥ 0.65, got ${findings[0].similarity}`); +}); + +test("scanVerbatimDuplication: ignores PR-modified files in source candidates", async () => { + const sharedCode = Array.from({ length: 8 }, (_, i) => `const line${i} = ${i};`).join("\n"); + const prPatch = "@@ -1,0 +1,8 @@\n" + sharedCode.split("\n").map((l) => `+${l}`).join("\n"); + + const mockFetch = async (url: string) => { + if (String(url).includes("/git/trees/")) { + return { + ok: true, + json: async () => ({ + // The only candidate file IS the file being modified — should be excluded. + tree: [{ path: "src/target.ts", type: "blob", sha: "blob1", size: 200 }], + }), + }; + } + // Should never fetch a blob since the only candidate is excluded. + throw new Error("blob fetch should not happen"); + }; + + const findings = await scanVerbatimDuplication( + { + repoFullName: "o/r", + prNumber: 1, + headSha: "abc", + githubToken: "tok", + files: [{ path: "src/target.ts", patch: prPatch }], + }, + mockFetch as typeof fetch, + ); + assert.deepEqual(findings, []); +}); + +test("scanVerbatimDuplication: forwards abort signal to fetch calls", async () => { + const controller = new AbortController(); + const signals: AbortSignal[] = []; + + const trackingFetch = async (_url: string, init?: RequestInit): Promise => { + if (init?.signal) signals.push(init.signal as AbortSignal); + if (String(_url).includes("/git/trees/")) { + return { + ok: true, + json: async () => ({ tree: [] }), + } as Response; + } + return { ok: false, json: async () => ({}) } as Response; + }; + + await scanVerbatimDuplication( + { + repoFullName: "o/r", + prNumber: 1, + headSha: "abc", + githubToken: "tok", + files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,6 @@\n+a\n+b\n+c\n+d\n+e\n+f" }], + }, + trackingFetch as typeof fetch, + { signal: controller.signal }, + ); + + assert.ok(signals.length > 0, "abort signal should be forwarded"); + assert.ok(signals.every((s) => s === controller.signal)); +}); + +test("renderBrief: renders the duplication block with head:line and source:line citation", () => { + const r = renderBrief({ + duplication: [ + { + headFile: "src/new.ts", + headLine: 15, + sourceFile: "src/existing.ts", + sourceLine: 42, + lineCount: 8, + similarity: 0.82, + }, + ], + }); + assert.match(r.promptSection, /Near-verbatim code duplication/); + assert.match(r.promptSection, /`src\/new\.ts:15`/); + assert.match(r.promptSection, /`src\/existing\.ts:42`/); + assert.match(r.promptSection, /~8 lines, 82% match/); + assert.match(r.promptSection, /refactor to import/); +}); + +test("buildBrief: duplication analyzer runs and is wired into the orchestrator", async () => { + const realFetch = globalThis.fetch; + // Tree returns no same-language files → analyzer returns [] successfully (not degraded). + globalThis.fetch = async (url) => { + if (String(url).includes("/git/trees/")) { + return { ok: true, json: async () => ({ tree: [] }) }; + } + return { ok: true, json: async () => ({}) }; + }; + try { + const brief = await buildBrief({ + repoFullName: "o/r", + prNumber: 1, + headSha: "abc", + githubToken: "test-tok", + files: [{ path: "src/x.ts", patch: "@@ -1,0 +1,6 @@\n+a\n+b\n+c\n+d\n+e\n+f" }], + }); + assert.equal(brief.analyzerStatus.duplication, "ok"); + assert.deepEqual(brief.findings.duplication, []); + } finally { + globalThis.fetch = realFetch; + } +});