diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 1eeabda074..c3ccd72c8b 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -210,14 +210,13 @@ export type FocusManifestGateConfig = { * or dashboard toggle. Deliberately a DEDICATED 4-value enum, not the shared `GateRuleMode` tri-state: the * issue's tiered response is warn -> label -> block -> strikes, where "strikes" is a separate escalation * action (reusing the existing cross-repo banned-contributors ledger once wired) rather than a 5th mode - * value. THIS FIELD IS CURRENTLY INERT -- the similarity/containment detection engine that would actually - * compute a copycat finding does not exist yet (tracked as later, separate PRs against #1969); parsing and - * threading this config end-to-end first proves the plumbing and lets an operator's `.loopover.yml` - * already declare intent without waiting on the detection engine. */ + * value. The pure containment engine now lives at `src/signals/copycat.ts` (Phase 1); CALL-SITE wiring + * that feeds prior art and actuates label/block/strikes is still deferred (Phase 2/3 against #1969). + * Parsing and threading this config end-to-end lets an operator's `.loopover.yml` already declare intent. */ copycatMode: CopycatGateMode | null; /** `gate.copycat.minScore` (#1969): containment/similarity score (0-100) at/above which `copycatMode` acts. - * null (unset) ⇒ the (also currently inert) engine's own default threshold once it exists. Same 0-100 - * clamp-and-round normalization as `slopMinScore`/`readinessMinScore` above. */ + * null (unset) ⇒ the engine's own default threshold (`DEFAULT_COPYCAT_MIN_SCORE` in `src/signals/copycat.ts`). + * Same 0-100 clamp-and-round normalization as `slopMinScore`/`readinessMinScore` above. */ copycatMinScore: number | null; }; diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index 57f5ee0cf4..7e9d60aa92 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -213,12 +213,12 @@ export type RepositorySettings = { * for check-run detection so contributor-controlled same-name runs cannot satisfy a blocking CLA gate. */ claCheckRunAppSlug?: string | null | undefined; /** Copycat/plagiarism detection (#1969). `off` (default/absent) = no check; `warn`/`label`/`block` are - * escalating tiers a future containment/similarity engine would act on. Config-as-code only — no DB column - * or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. CURRENTLY INERT: parsed and threaded - * end-to-end, but no detection engine reads it yet. */ + * escalating tiers the containment engine (`src/signals/copycat.ts`) would act on. Config-as-code only — + * no DB column or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. CALL-SITE STILL INERT: + * the pure detection engine exists (Phase 1); advisory/processors wiring is deferred Phase 2/3. */ copycatGateMode?: "off" | "warn" | "label" | "block" | undefined; - /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` would act, - * once the detection engine exists. Config-as-code only, alongside {@link copycatGateMode}. */ + /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` would act + * once call-site wiring lands. Config-as-code only, alongside {@link copycatGateMode}. */ copycatGateMinScore?: number | null | undefined; /** `gate.expectedCiContexts` (#selfhost-ci-verification): maintainer-declared CI check/status context names to * treat as required when GitHub branch protection returns no readable required-status-checks (unconfigured, diff --git a/src/signals/copycat.ts b/src/signals/copycat.ts new file mode 100644 index 0000000000..a4c53b979d --- /dev/null +++ b/src/signals/copycat.ts @@ -0,0 +1,238 @@ +// Deterministic copycat / plagiarism containment engine (#1969 Phase 1; #1409 design). +// Sibling of the anti-slop signal and of packages/loopover-engine's self-plagiarism throttle: pure, +// no IO, no Date.now(), no randomness — identical inputs always yield the identical verdict. +// +// What it measures: the asymmetric containment of THIS candidate's added-code shingles inside an +// earlier piece of prior art (answers "how much of THIS PR is copied FROM prior art"). Direction +// by submission timestamp is load-bearing — the earlier submission is the victim; only the later +// one can be the copycat. Missing / unparseable / tied timestamps are `ambiguous` and never act. +// +// DETECTOR ONLY — gates nothing on its own. The already-scaffolded `gate.copycat.mode` / +// `gate.copycat.minScore` config (#4140) is still inert at the call site; wiring this engine into +// advisory / processors (label → block → strikes) is a deferred Phase 2/3 follow-up against #1969. +// False-accusation-averse: a finding is emitted only when mode is non-`off`, the score clears the +// threshold, the candidate has enough added lines, authors differ, AND direction is unambiguously +// `candidate_copied`. + +import type { AdvisoryFinding, AdvisorySeverity, CopycatGateMode } from "../types"; + +/** Conservative default — only very high containment scores would act (precision-first, #1409). */ +export const DEFAULT_COPYCAT_MIN_SCORE = 85; + +/** Minimum added (normalized) lines on the candidate before containment is even considered — a + * tiny high-% overlap is not theft. */ +export const DEFAULT_COPYCAT_MIN_ADDED_LINES = 10; + +/** Multi-line shingle width. Short incidental coincidences (`}`, `return null;`) must not inflate + * the score. Deliberate false-negative tradeoff: a verbatim copy shorter than this width collapses + * to a single whole-block token and only matches an identically-short prior-art block (#5129). */ +export const COPYCAT_SHINGLE_SIZE = 3; + +export type CopycatDirection = "candidate_copied" | "prior_copied" | "ambiguous"; + +export type CopycatSubmission = { + /** Pull request number (or other stable id) of this submission — surfaced in findings when present. */ + pullNumber?: number | null | undefined; + /** ISO-8601 submission timestamp — earlier submission = original/victim. */ + submittedAt?: string | null | undefined; + /** GitHub login of the author; same-author pairs never produce a finding. */ + authorLogin?: string | null | undefined; +}; + +export type CopycatAssessmentInput = { + /** Added code lines of the PR under review (caller-normalized: exclude_paths already applied). */ + candidateLines: readonly string[]; + /** Added code lines of one piece of prior art (earlier open / recently merged/closed PR). */ + priorArtLines: readonly string[]; + candidate: CopycatSubmission; + priorArt: CopycatSubmission; + /** `gate.copycat.mode`. `off`/absent ⇒ score is still computed for observability, but no finding. */ + mode?: CopycatGateMode | null | undefined; + /** `gate.copycat.minScore` (0-100). Non-finite / out-of-range clamps to the default. */ + minScore?: number | null | undefined; + /** Override for the minimum-added-lines floor (tests / advanced callers). */ + minAddedLines?: number | null | undefined; +}; + +export type CopycatAssessment = { + /** Asymmetric containment of the candidate inside prior art, rounded 0-100. */ + containmentScore: number; + direction: CopycatDirection; + /** True when a public finding would act under the configured mode + guards. */ + wouldAct: boolean; + /** Public-safe finding when `wouldAct` is true; otherwise null. */ + finding: AdvisoryFinding | null; + /** Resolved threshold actually used (after clamping). */ + resolvedMinScore: number; + /** Normalized added-line count on the candidate (post whitespace/comment strip). */ + candidateAddedLines: number; +}; + +function normalizeLine(line: string): string { + // Strip // and # line comments, then collapse whitespace + lowercase so reformatting never reads + // as novel content. Block comments / strings are left alone — a perfect strip would need a parser + // and this detector is deliberately format-agnostic and dependency-free. + return line + .replace(/\/\/.*$/, "") + .replace(/#.*$/, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +/** Normalize + drop blanks; exported for tests. */ +export function normalizeAddedLines(lines: readonly string[]): string[] { + const out: string[] = []; + for (const line of lines) { + if (typeof line !== "string") continue; + const normalized = normalizeLine(line); + if (normalized) out.push(normalized); + } + return out; +} + +/** + * Sliding multi-line shingles as a LIST (multiset). A candidate with repeated shingles must count + * each occurrence toward containment — treating the candidate as a Set would under-count the + * advertised "percentage of the candidate's added-code shingles" (#5129 blocker). Exported for tests. + */ +export function codeShingleList(normalizedLines: readonly string[], width = COPYCAT_SHINGLE_SIZE): string[] { + if (normalizedLines.length === 0) return []; + if (normalizedLines.length < width) { + // Whole-block fallback for short candidates — documented FN for a short verbatim lift out of a + // longer prior-art file (see module header / #5129 nit). + return [normalizedLines.join("\n")]; + } + const shingles: string[] = []; + for (let index = 0; index <= normalizedLines.length - width; index += 1) { + shingles.push(normalizedLines.slice(index, index + width).join("\n")); + } + return shingles; +} + +/** + * Asymmetric containment: fraction of the CANDIDATE's shingles (multiset) found in prior art, + * rounded to a 0-100 percentage. Empty candidate ⇒ 0 (nothing to accuse). Empty prior with a + * non-empty candidate ⇒ 0. + */ +export function containmentScore(candidateLines: readonly string[], priorArtLines: readonly string[]): number { + const candidate = codeShingleList(normalizeAddedLines(candidateLines)); + if (candidate.length === 0) return 0; + const prior = new Set(codeShingleList(normalizeAddedLines(priorArtLines))); + if (prior.size === 0) return 0; + let contained = 0; + for (const shingle of candidate) { + if (prior.has(shingle)) contained += 1; + } + return Math.round((contained / candidate.length) * 100); +} + +function parseTimestampMs(value: string | null | undefined): number | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const ms = Date.parse(trimmed); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Direction by timestamp. Earlier submission = original/victim. Ties and missing/unparseable + * timestamps are `ambiguous` — never act on them (false-accusation-averse). + */ +export function resolveCopycatDirection( + candidate: CopycatSubmission, + priorArt: CopycatSubmission, +): CopycatDirection { + const candidateMs = parseTimestampMs(candidate.submittedAt); + const priorMs = parseTimestampMs(priorArt.submittedAt); + if (candidateMs === null || priorMs === null) return "ambiguous"; + if (candidateMs === priorMs) return "ambiguous"; + return candidateMs > priorMs ? "candidate_copied" : "prior_copied"; +} + +export function resolveCopycatMinScore(minScore: number | null | undefined): number { + if (typeof minScore !== "number" || !Number.isFinite(minScore)) return DEFAULT_COPYCAT_MIN_SCORE; + return Math.min(100, Math.max(0, Math.round(minScore))); +} + +function resolveMinAddedLines(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return DEFAULT_COPYCAT_MIN_ADDED_LINES; + } + return Math.round(value); +} + +function normalizeLogin(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed : null; +} + +function severityForMode(mode: CopycatGateMode): AdvisorySeverity { + if (mode === "block") return "critical"; + if (mode === "label") return "warning"; + return "info"; +} + +function buildCopycatFinding(args: { + mode: CopycatGateMode; + score: number; + threshold: number; + priorPullNumber: number | null; +}): AdvisoryFinding { + const sourceRef = + args.priorPullNumber !== null && args.priorPullNumber > 0 ? ` earlier PR #${args.priorPullNumber}` : " earlier prior art"; + const detail = + `This pull request's added code overlaps${sourceRef} at ${args.score}% containment ` + + `(threshold ${args.threshold}%). Please confirm originality or attribution before merge.`; + return { + code: "copycat_containment", + title: "Possible copycat of earlier work", + severity: severityForMode(args.mode), + detail, + action: "Confirm the change is original or properly attributed, or close if it duplicates earlier work.", + // Public-safe: score + threshold + optional prior PR number only — never raw code, paths, or author identity. + publicText: detail, + }; +} + +/** + * Assess one candidate against one piece of prior art under the configured `gate.copycat` mode. + * Pure / fail-safe: never throws; never flags the earlier victim or same-author pairs. + */ +export function assessCopycat(input: CopycatAssessmentInput): CopycatAssessment { + const mode: CopycatGateMode = + input.mode === "warn" || input.mode === "label" || input.mode === "block" ? input.mode : "off"; + const resolvedMinScore = resolveCopycatMinScore(input.minScore); + const minAddedLines = resolveMinAddedLines(input.minAddedLines); + const normalizedCandidate = normalizeAddedLines(input.candidateLines ?? []); + const score = containmentScore(input.candidateLines ?? [], input.priorArtLines ?? []); + const direction = resolveCopycatDirection(input.candidate ?? {}, input.priorArt ?? {}); + + const candidateLogin = normalizeLogin(input.candidate?.authorLogin); + const priorLogin = normalizeLogin(input.priorArt?.authorLogin); + const sameAuthor = candidateLogin !== null && priorLogin !== null && candidateLogin === priorLogin; + + const priorPull = + typeof input.priorArt?.pullNumber === "number" && Number.isFinite(input.priorArt.pullNumber) + ? Math.trunc(input.priorArt.pullNumber) + : null; + + const wouldAct = + mode !== "off" && + !sameAuthor && + direction === "candidate_copied" && + normalizedCandidate.length >= minAddedLines && + score >= resolvedMinScore; + + return { + containmentScore: score, + direction, + wouldAct, + finding: wouldAct + ? buildCopycatFinding({ mode, score, threshold: resolvedMinScore, priorPullNumber: priorPull }) + : null, + resolvedMinScore, + candidateAddedLines: normalizedCandidate.length, + }; +} diff --git a/src/types.ts b/src/types.ts index be3f73bae9..42e0713bd7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -616,7 +616,7 @@ export type GateRuleMode = "off" | "advisory" | "block"; /** `gate.copycat.mode` (#1969) -- a dedicated 4-value enum rather than the shared {@link GateRuleMode} * tri-state, since the issue's tiered response is warn -> label -> block -> strikes (where "strikes" is a * separate escalation action reusing the existing cross-repo banned-contributors ledger, not a 5th mode - * value). See {@link RepositorySettings.copycatGateMode}'s doc comment for the currently-inert status. */ + * value). See {@link RepositorySettings.copycatGateMode}'s doc comment for engine-vs-call-site status. */ export type CopycatGateMode = "off" | "warn" | "label" | "block"; /** Review-check publish surface (#2852). Controls ONLY whether/how the "LoopOver Orb Review Agent" check-run @@ -783,14 +783,15 @@ export type RepositorySettings = { * for check-run detection so contributor-controlled same-name runs cannot satisfy a blocking CLA gate. */ claCheckRunAppSlug?: string | null | undefined; /** Copycat/plagiarism detection (#1969). `off` (default/absent) = no check; `warn`/`label`/`block` are - * escalating tiers a future containment/similarity engine would act on (`block` additionally hard-blocks; - * a further "strikes" escalation reuses the existing cross-repo banned-contributors ledger once wired). - * Config-as-code only — no DB column or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. - * CURRENTLY INERT: this field is parsed and threaded end-to-end, but no detection engine reads it yet — - * see {@link CopycatGateMode}'s doc comment in packages/loopover-engine for the tracked follow-up plan. */ + * escalating tiers the containment engine ({@link assessCopycat} in `src/signals/copycat.ts`) would act + * on (`block` additionally hard-blocks; a further "strikes" escalation reuses the existing cross-repo + * banned-contributors ledger once wired). Config-as-code only — no DB column or dashboard toggle; set + * via `.loopover.yml gate.copycat.mode`. CALL-SITE STILL INERT: the pure detection engine exists and is + * unit-tested, but advisory/processors do not yet feed real prior-art into it or actuate label/block/ + * strikes — that wiring is a deferred Phase 2/3 follow-up against #1969. */ copycatGateMode?: CopycatGateMode | undefined; - /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` would act, - * once the detection engine exists. `null`/absent ⇒ the engine's own default threshold. Config-as-code + /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` would act + * once call-site wiring lands. `null`/absent ⇒ the engine's own default threshold. Config-as-code * only, alongside {@link copycatGateMode}. */ copycatGateMinScore?: number | null | undefined; /** `gate.expectedCiContexts` (#selfhost-ci-verification): maintainer-declared CI check/status context names to diff --git a/test/unit/copycat.test.ts b/test/unit/copycat.test.ts new file mode 100644 index 0000000000..ada596ce73 --- /dev/null +++ b/test/unit/copycat.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; +import { + COPYCAT_SHINGLE_SIZE, + DEFAULT_COPYCAT_MIN_ADDED_LINES, + DEFAULT_COPYCAT_MIN_SCORE, + assessCopycat, + codeShingleList, + containmentScore, + normalizeAddedLines, + resolveCopycatDirection, + resolveCopycatMinScore, +} from "../../src/signals/copycat"; + +/** Build N distinct, long-enough lines so sliding 3-line shingles are well-formed. */ +function lines(prefix: string, count: number): string[] { + return Array.from({ length: count }, (_unused, index) => `${prefix}_line_${index}_alpha_bravo_charlie();`); +} + +describe("normalizeAddedLines / codeShingleList", () => { + it("strips comments, collapses whitespace, drops blanks, and lowercases", () => { + expect( + normalizeAddedLines([ + " Const X = 1; // trailing", + "", + "# comment only", + "\tConst Y = 2;", + 42 as unknown as string, + ]), + ).toEqual(["const x = 1;", "const y = 2;"]); + }); + + it("returns an empty list for empty input and a single whole-block for sub-width input", () => { + expect(codeShingleList([])).toEqual([]); + expect(codeShingleList(["a", "b"])).toEqual(["a\nb"]); + }); + + it("slides a multiset of width-sized shingles (duplicates preserved)", () => { + const shingles = codeShingleList(["a", "b", "c", "a", "b", "c"], 3); + expect(shingles).toEqual(["a\nb\nc", "b\nc\na", "c\na\nb", "a\nb\nc"]); + expect(COPYCAT_SHINGLE_SIZE).toBe(3); + }); +}); + +describe("containmentScore (asymmetric multiset)", () => { + it("returns 0 for empty candidate or empty prior art", () => { + expect(containmentScore([], lines("prior", 12))).toBe(0); + expect(containmentScore(lines("cand", 12), [])).toBe(0); + expect(containmentScore([], [])).toBe(0); + }); + + it("scores 100 when the candidate's added lines are fully contained in prior art", () => { + const prior = lines("shared", 12); + const candidate = prior.slice(0, 10); + expect(containmentScore(candidate, prior)).toBe(100); + }); + + it("scores 0 when there is no overlapping shingle", () => { + expect(containmentScore(lines("left", 12), lines("right", 12))).toBe(0); + }); + + it("counts repeated candidate shingles as a multiset, not a Set (#5129 blocker)", () => { + // Sliding 3-line windows over [a,b,c,a,b,c] yield [abc, bca, cab, abc]. Prior art holding only + // the [a,b,c] block contributes the single shingle `abc`. Multiset scoring ⇒ 2/4 = 50%. Scoring + // the candidate as a Set instead would under-count as 1/3 ≈ 33% — the #5129 review failure mode. + const block = ["shared_a();", "shared_b();", "shared_c();"]; + const repeated = [...block, ...block]; + expect(containmentScore(repeated, block)).toBe(50); + }); + + it("is reformatting-invariant (whitespace / comment / case)", () => { + const prior = ["const Token = 1;", "const Other = 2;", "const Third = 3;", "const Fourth = 4;"]; + const candidate = [ + " CONST token = 1; // noise", + "const Other = 2;", + "CONST third = 3;", + "const fourth = 4;", + ]; + expect(containmentScore(candidate, prior)).toBe(100); + }); + + it("documents the short-snippet false-negative tradeoff (< SHINGLE_SIZE)", () => { + // Two-line verbatim lift out of a longer prior-art file scores 0 because the whole-block + // candidate token never appears as a 3-line prior shingle. + const prior = lines("long", 12); + const shortLift = prior.slice(0, 2); + expect(shortLift.length).toBeLessThan(COPYCAT_SHINGLE_SIZE); + expect(containmentScore(shortLift, prior)).toBe(0); + }); +}); + +describe("resolveCopycatDirection / resolveCopycatMinScore", () => { + it("marks the later submission as candidate_copied and the earlier as prior_copied", () => { + expect( + resolveCopycatDirection( + { submittedAt: "2026-07-01T12:11:00.000Z" }, + { submittedAt: "2026-07-01T12:00:00.000Z" }, + ), + ).toBe("candidate_copied"); + expect( + resolveCopycatDirection( + { submittedAt: "2026-07-01T12:00:00.000Z" }, + { submittedAt: "2026-07-01T12:11:00.000Z" }, + ), + ).toBe("prior_copied"); + }); + + it("returns ambiguous on ties, missing, blank, or unparseable timestamps", () => { + expect( + resolveCopycatDirection( + { submittedAt: "2026-07-01T12:00:00.000Z" }, + { submittedAt: "2026-07-01T12:00:00.000Z" }, + ), + ).toBe("ambiguous"); + expect(resolveCopycatDirection({ submittedAt: null }, { submittedAt: "2026-07-01T12:00:00.000Z" })).toBe( + "ambiguous", + ); + expect(resolveCopycatDirection({ submittedAt: "2026-07-01T12:00:00.000Z" }, { submittedAt: " " })).toBe( + "ambiguous", + ); + expect(resolveCopycatDirection({ submittedAt: "not-a-date" }, { submittedAt: "2026-07-01T12:00:00.000Z" })).toBe( + "ambiguous", + ); + expect(resolveCopycatDirection({}, {})).toBe("ambiguous"); + }); + + it("clamps / defaults minScore", () => { + expect(resolveCopycatMinScore(undefined)).toBe(DEFAULT_COPYCAT_MIN_SCORE); + expect(resolveCopycatMinScore(null)).toBe(DEFAULT_COPYCAT_MIN_SCORE); + expect(resolveCopycatMinScore(Number.NaN)).toBe(DEFAULT_COPYCAT_MIN_SCORE); + expect(resolveCopycatMinScore(-10)).toBe(0); + expect(resolveCopycatMinScore(150)).toBe(100); + expect(resolveCopycatMinScore(87.4)).toBe(87); + }); +}); + +describe("assessCopycat", () => { + const priorLines = lines("shared", 14); + const candidateLines = priorLines.slice(0, 12); + const later = { + pullNumber: 20, + submittedAt: "2026-07-01T12:11:00.000Z", + authorLogin: "copycat", + }; + const earlier = { + pullNumber: 10, + submittedAt: "2026-07-01T12:00:00.000Z", + authorLogin: "original", + }; + + it("emits a finding when mode/score/direction/min-lines/authors all clear the precision guards", () => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "block", + minScore: 85, + }); + expect(result.containmentScore).toBe(100); + expect(result.direction).toBe("candidate_copied"); + expect(result.wouldAct).toBe(true); + expect(result.finding?.code).toBe("copycat_containment"); + expect(result.finding?.severity).toBe("critical"); + expect(result.finding?.detail).toContain("PR #10"); + expect(result.finding?.detail).toContain("100%"); + expect(result.finding?.publicText).toBe(result.finding?.detail); + expect(result.candidateAddedLines).toBeGreaterThanOrEqual(DEFAULT_COPYCAT_MIN_ADDED_LINES); + }); + + it.each([ + ["warn", "info"], + ["label", "warning"], + ["block", "critical"], + ] as const)("maps mode %s to severity %s", (mode, severity) => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode, + }); + expect(result.finding?.severity).toBe(severity); + }); + + it("never acts when mode is off (score still computed)", () => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "off", + }); + expect(result.containmentScore).toBe(100); + expect(result.wouldAct).toBe(false); + expect(result.finding).toBeNull(); + }); + + it("never acts when the candidate is the earlier victim", () => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: earlier, + priorArt: later, + mode: "warn", + }); + expect(result.direction).toBe("prior_copied"); + expect(result.wouldAct).toBe(false); + expect(result.finding).toBeNull(); + }); + + it("never acts on ambiguous timestamps or same-author pairs", () => { + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: { ...later, submittedAt: null }, + priorArt: earlier, + mode: "label", + }).wouldAct, + ).toBe(false); + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: { ...later, authorLogin: "SameDev" }, + priorArt: { ...earlier, authorLogin: "samedev" }, + mode: "block", + }).wouldAct, + ).toBe(false); + }); + + it("never acts below the threshold or below the min-added-lines floor", () => { + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "block", + minScore: 100, + }).wouldAct, + ).toBe(true); + expect( + assessCopycat({ + candidateLines: lines("unique", 12), + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "block", + minScore: 85, + }).wouldAct, + ).toBe(false); + expect( + assessCopycat({ + candidateLines: priorLines.slice(0, 4), + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "warn", + minAddedLines: 10, + }).wouldAct, + ).toBe(false); + }); + + it("treats absent/unknown mode as off and falls back to the default threshold", () => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: undefined, + minScore: undefined, + }); + expect(result.wouldAct).toBe(false); + expect(result.resolvedMinScore).toBe(DEFAULT_COPYCAT_MIN_SCORE); + }); + + it("omits the prior PR number from the finding when it is missing", () => { + const result = assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: { ...earlier, pullNumber: null }, + mode: "warn", + }); + expect(result.finding?.detail).toContain("earlier prior art"); + expect(result.finding?.detail).not.toContain("PR #"); + }); + + it("covers blank logins, zero/NaN prior PR numbers, and undefined inputs", () => { + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: { ...later, authorLogin: " " }, + priorArt: earlier, + mode: "block", + }).wouldAct, + ).toBe(true); + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: { ...earlier, pullNumber: 0 }, + mode: "warn", + }).finding?.detail, + ).toContain("earlier prior art"); + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: { ...earlier, pullNumber: Number.NaN }, + mode: "label", + }).finding?.detail, + ).toContain("earlier prior art"); + // Undefined lines / submissions take the empty-default arms without throwing. + const empty = assessCopycat({ + candidateLines: undefined as unknown as string[], + priorArtLines: undefined as unknown as string[], + candidate: undefined as unknown as typeof later, + priorArt: undefined as unknown as typeof earlier, + mode: "warn", + minAddedLines: -1, + }); + expect(empty.containmentScore).toBe(0); + expect(empty.wouldAct).toBe(false); + expect(empty.candidateAddedLines).toBe(0); + expect( + assessCopycat({ + candidateLines, + priorArtLines: priorLines, + candidate: later, + priorArt: earlier, + mode: "block", + minAddedLines: 5.6, + }).wouldAct, + ).toBe(true); + }); +});