From 814638101a4916e16a8aed01b6686f452af65959 Mon Sep 17 00:00:00 2001
From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com>
Date: Wed, 15 Jul 2026 08:16:24 +0200
Subject: [PATCH] feat(review): wire the copycat containment engine into the
gate
Implements the deterministic containment/similarity engine gate.copycat.mode
was parsed for since #4140 but never acted on. Scores a PR's added lines
against a bounded, precision-first candidate set of earlier open siblings and
recently-merged PRs on the same repo, resolving copy direction by submission
timestamp so the original author is never flagged. warn surfaces an advisory
finding; label also applies a label without blocking review; block also
closes the PR and feeds the existing moderation-rules strikes ledger.
Closes #1969
---
.loopover.yml.example | 19 +-
.../src/routes/docs.how-reviews-work.tsx | 9 +-
apps/loopover-ui/src/routes/docs.tuning.tsx | 12 +-
config/examples/loopover.full.yml | 19 +-
.../0151_pull_request_copycat_assessment.sql | 5 +
.../loopover-engine/src/focus-manifest.ts | 13 +-
.../src/settings/moderation-rules.ts | 10 +-
.../loopover-engine/src/signals/copycat.ts | 246 ++++++++++++++
.../src/types/manifest-deps-types.ts | 11 +-
src/db/repositories.ts | 24 ++
src/db/schema.ts | 6 +
src/queue/copycat-detection.ts | 92 ++++++
src/queue/processors.ts | 36 ++-
src/review/review-diff.ts | 13 +
src/settings/agent-actions.ts | 84 ++++-
src/signals/copycat.ts | 21 ++
src/types.ts | 31 +-
test/unit/agent-actions.test.ts | 119 ++++++-
test/unit/copycat-detection.test.ts | 211 ++++++++++++
test/unit/copycat.test.ts | 305 ++++++++++++++++++
test/unit/data-spine.test.ts | 27 ++
test/unit/moderation-rules-engine.test.ts | 10 +
test/unit/moderation-rules.test.ts | 10 +
test/unit/queue-4.test.ts | 40 +++
test/unit/review-diff.test.ts | 22 +-
25 files changed, 1336 insertions(+), 59 deletions(-)
create mode 100644 migrations/0151_pull_request_copycat_assessment.sql
create mode 100644 packages/loopover-engine/src/signals/copycat.ts
create mode 100644 src/queue/copycat-detection.ts
create mode 100644 src/signals/copycat.ts
create mode 100644 test/unit/copycat-detection.test.ts
create mode 100644 test/unit/copycat.test.ts
diff --git a/.loopover.yml.example b/.loopover.yml.example
index cd65173dc7..27d795d743 100644
--- a/.loopover.yml.example
+++ b/.loopover.yml.example
@@ -187,18 +187,19 @@ gate:
# the gate. Bool. Default: false.
aiAdvisory: false
- # Copycat/plagiarism detection (#1969). CURRENTLY INERT — this config is parsed
- # and threaded end-to-end, but the containment/similarity detection engine that
- # would actually compute a copycat finding does not exist yet (tracked as
- # separate, later PRs against #1969). Setting this today has no observable
- # effect; it exists so an operator's config can already declare intent.
+ # Copycat/plagiarism detection (#1969): a deterministic code-containment check against prior art
+ # (earlier open or recently merged PRs on the same repo), direction resolved by submission
+ # timestamp so the earlier (original) author is never flagged.
copycat:
# off | warn | label | block. Default: off. A dedicated 4-tier scale (not the
- # shared off/advisory/block used elsewhere) — a further "strikes" escalation
- # beyond block reuses the existing cross-repo banned-contributors ledger.
+ # shared off/advisory/block used elsewhere): warn surfaces an advisory finding
+ # only; label also applies a label; block also closes the PR and counts toward
+ # a further "strikes" escalation that reuses the existing moderation-rules
+ # violation ledger (escalating to the global contributor blacklist at the
+ # configured ban threshold).
mode: off
- # Containment/similarity threshold at/above which mode acts, once the
- # detection engine exists. Number 0–100, or null for the engine default.
+ # Containment/similarity threshold (0-100) at/above which mode acts.
+ # null uses the engine default (85).
minScore: null
# Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold
diff --git a/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx b/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
index 3a804bef55..349f976f6d 100644
--- a/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
+++ b/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
@@ -112,10 +112,11 @@ function HowReviewsWork() {
Copycat / plagiarism gate (copycatGateMode, default{" "}
- off) — a code containment/similarity check against prior art (repo history,
- other PRs). Escalating tiers: warn, label, block,
- plus a further strikes escalation for repeat offenders. Config only today — the detection
- engine itself has not shipped yet, so setting this has no effect until it does.
+ off) — a code containment/similarity check against prior art (earlier open or
+ recently merged PRs on the same repo). Escalating tiers: warn surfaces an
+ advisory finding only; label also applies a label; block also
+ closes the PR and counts toward the repeat-offender strikes ledger. The direction is
+ always by submission timestamp, so the earlier (original) author is never flagged.
Merge-readiness gate (mergeReadinessGateMode, default{" "}
diff --git a/apps/loopover-ui/src/routes/docs.tuning.tsx b/apps/loopover-ui/src/routes/docs.tuning.tsx
index 8587da9f22..3d58872c32 100644
--- a/apps/loopover-ui/src/routes/docs.tuning.tsx
+++ b/apps/loopover-ui/src/routes/docs.tuning.tsx
@@ -336,11 +336,13 @@ function Tuning() {
or the gate.
- gate.copycat.mode — code containment/similarity gate against prior art (repo
- history, other PRs). Default off. Escalating tiers: warn,{" "}
- label, block, plus a further strikes escalation for repeat
- offenders. Pair it with gate.copycat.minScore (0–100). Config only today —
- the detection engine has not shipped yet, so this has no effect until it does.
+ gate.copycat.mode — code containment/similarity gate against prior art
+ (earlier open or recently merged PRs on the same repo). Default off.
+ Escalating tiers: warn surfaces an advisory finding only; label{" "}
+ also applies a label; block also closes the PR and counts toward the
+ repeat-offender strikes ledger. Pair it with gate.copycat.minScore (0–100;{" "}
+ null uses the engine default, 85). Direction is always by
+ submission timestamp, so the earlier (original) author is never flagged.
gate.mergeReadiness — composite merge-readiness gate. Default{" "}
diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml
index 6dc4672b75..d931d7b5d4 100644
--- a/config/examples/loopover.full.yml
+++ b/config/examples/loopover.full.yml
@@ -201,18 +201,19 @@ gate:
# the gate. Bool. Default: false.
aiAdvisory: false
- # Copycat/plagiarism detection (#1969). CURRENTLY INERT — this config is parsed
- # and threaded end-to-end, but the containment/similarity detection engine that
- # would actually compute a copycat finding does not exist yet (tracked as
- # separate, later PRs against #1969). Setting this today has no observable
- # effect; it exists so an operator's config can already declare intent.
+ # Copycat/plagiarism detection (#1969): a deterministic code-containment check against prior art
+ # (earlier open or recently merged PRs on the same repo), direction resolved by submission
+ # timestamp so the earlier (original) author is never flagged.
copycat:
# off | warn | label | block. Default: off. A dedicated 4-tier scale (not the
- # shared off/advisory/block used elsewhere) — a further "strikes" escalation
- # beyond block reuses the existing cross-repo banned-contributors ledger.
+ # shared off/advisory/block used elsewhere): warn surfaces an advisory finding
+ # only; label also applies a label; block also closes the PR and counts toward
+ # a further "strikes" escalation that reuses the existing moderation-rules
+ # violation ledger (escalating to the global contributor blacklist at the
+ # configured ban threshold).
mode: off
- # Containment/similarity threshold at/above which mode acts, once the
- # detection engine exists. Number 0–100, or null for the engine default.
+ # Containment/similarity threshold (0-100) at/above which mode acts.
+ # null uses the engine default (85).
minScore: null
# Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold
diff --git a/migrations/0151_pull_request_copycat_assessment.sql b/migrations/0151_pull_request_copycat_assessment.sql
new file mode 100644
index 0000000000..62943b5592
--- /dev/null
+++ b/migrations/0151_pull_request_copycat_assessment.sql
@@ -0,0 +1,5 @@
+-- Latest deterministic copycat/plagiarism containment assessment (#1969), persisted separately from the
+-- GitHub sync so a later maintenance pass (which re-reads the stored PR row rather than re-running the
+-- containment engine) can act on it -- mirrors slop_risk/slop_band (see the pull_requests table's own comment).
+ALTER TABLE pull_requests ADD COLUMN copycat_score INTEGER;
+ALTER TABLE pull_requests ADD COLUMN copycat_matched_pull_number INTEGER;
diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts
index 1eeabda074..ba7de4eb4c 100644
--- a/packages/loopover-engine/src/focus-manifest.ts
+++ b/packages/loopover-engine/src/focus-manifest.ts
@@ -209,15 +209,14 @@ export type FocusManifestGateConfig = {
/** `gate.copycat.mode` (#1969): off|warn|label|block, off by default. Config-as-code only -- no DB column
* 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. */
+ * action (reusing the existing moderation-rules violation ledger -- a `block`-tier close is tagged
+ * `closeKind: "copycat"`, counted the same way as blacklist/contributor_cap/review_nag) rather than a 5th
+ * mode value. Read by src/queue/copycat-detection.ts's deterministic containment engine, evaluated
+ * alongside slop in src/queue/processors.ts. */
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 (85). Same 0-100 clamp-and-round normalization as
+ * `slopMinScore`/`readinessMinScore` above. */
copycatMinScore: number | null;
};
diff --git a/packages/loopover-engine/src/settings/moderation-rules.ts b/packages/loopover-engine/src/settings/moderation-rules.ts
index 787bcb4d24..ad7f1f0b43 100644
--- a/packages/loopover-engine/src/settings/moderation-rules.ts
+++ b/packages/loopover-engine/src/settings/moderation-rules.ts
@@ -13,11 +13,14 @@
/** The anti-abuse mechanisms this engine can count violations from -- the three ORIGINAL mechanisms
* (contributor cap, blacklist, review-nag) plus review-evasion (#review-evasion-protection: a contributor
- * closing/converting-to-draft their own PR to dodge an active review). Kept as a closed union (not an open
+ * closing/converting-to-draft their own PR to dodge an active review) and copycat/plagiarism containment
+ * (#1969: the "strikes" tier of the copycat gate's own warn -> label -> block -> strikes response reuses
+ * this SAME generic violation ledger rather than a bespoke counter -- a `block`-tier copycat close is tagged
+ * `closeKind: "copycat"`, counted exactly like any other rule here). Kept as a closed union (not an open
* string) so an unrecognized value is always a normalization error, never silently accepted. */
-export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag" | "review_evasion";
+export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag" | "review_evasion" | "copycat";
-const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag", "review_evasion"];
+const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag", "review_evasion", "copycat"];
/** The `audit_events.event_type` recorded for each rule's violation -- namespaced under `moderation.violation.*`
* so a cross-eventType, cross-repo count query (see `db/repositories.ts`) can scope to exactly this family. */
@@ -26,6 +29,7 @@ export const MODERATION_VIOLATION_EVENT_TYPE: Record
blacklist: "moderation.violation.blacklist",
review_nag: "moderation.violation.review_nag",
review_evasion: "moderation.violation.review_evasion",
+ copycat: "moderation.violation.copycat",
};
export const DEFAULT_MODERATION_WARNING_LABEL = "mod:warning";
diff --git a/packages/loopover-engine/src/signals/copycat.ts b/packages/loopover-engine/src/signals/copycat.ts
new file mode 100644
index 0000000000..593e9ff205
--- /dev/null
+++ b/packages/loopover-engine/src/signals/copycat.ts
@@ -0,0 +1,246 @@
+import type { CopycatGateMode } from "../focus-manifest.js";
+import type { AdvisoryFinding, AdvisorySeverity } from "../types/predicted-gate-types.js";
+
+// Copycat / plagiarism detection engine (#1969) — the deterministic containment/similarity primitive that
+// `gate.copycat.mode` / `gate.copycat.minScore` (parsed end-to-end since #4140, but previously inert) acts on.
+// A natural sibling of the deterministic anti-slop signal (./slop.ts) and duplicate-cluster adjudication
+// (../duplicate-winner.js): given a PR's ADDED code and a SET of candidate prior-art PRs from the same repo, it
+// measures how much of the PR's added code is CONTAINED in each candidate, resolves copy DIRECTION by
+// submission timestamp (the earlier submission is the original, never the copier), picks the single
+// highest-scoring unambiguous match, and maps the result through the configured tier (warn -> label -> block)
+// into a public-safe finding.
+//
+// PURE / PRECISION-FIRST: no IO, no Date.now(), no randomness -- identical inputs always yield the identical
+// verdict. It is deliberately false-accusation-averse: it only ever `wouldAct` when the best-matching
+// candidate's score clears the threshold AND that candidate is unambiguously the EARLIER (victim) submission
+// AND a non-`off` mode is set. Any missing/ambiguous timestamp, or the candidate being the later work, is
+// excluded from consideration entirely -- so an earlier-submitted victim's own later, independent PR (zero
+// overlap with anything) evaluates normally, and a tie or unparseable timestamp never accuses anyone.
+//
+// Fetching the candidate set (earlier open + recently merged/closed PRs on the same repo) and each PR's added
+// lines is the caller's responsibility (src/queue/processors.ts) -- this module only scores what it's given.
+
+/** Precision-first default: only a HIGH containment (>= 85% of the PR's added code found in the prior art) trips
+ * the check when `gate.copycat.minScore` is unset. Mirrors the conservative 0.85 spirit of the miner-side
+ * self-plagiarism throttle (governor/self-plagiarism.ts's DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD). */
+export const DEFAULT_COPYCAT_MIN_SCORE = 85;
+
+/** Shingle width: consecutive normalized lines folded into one token, so containment reflects COPIED PASSAGES
+ * (multi-line runs) rather than incidental single-line coincidences (a lone `}` / `return null;`) that would
+ * inflate a naive line-set overlap. */
+const SHINGLE_SIZE = 3;
+
+/** Copy direction between the candidate PR and one prior-art submission, decided purely by submission time. */
+export type CopycatDirection = "candidate_copied" | "candidate_is_prior" | "ambiguous";
+
+/** Normalize one source line for structural comparison: collapse internal whitespace runs, trim, lowercase — so
+ * pure reformatting/indentation churn never reads as copied content. */
+function normalizeLine(line: string): string {
+ return line.replace(/\s+/g, " ").trim().toLowerCase();
+}
+
+/** Drop blank/whitespace-only lines and normalize the rest, preserving order. */
+function normalizedLines(lines: readonly string[]): string[] {
+ return lines.map(normalizeLine).filter((line) => line.length > 0);
+}
+
+/** Fold normalized lines into the ORDERED MULTISET of SHINGLE_SIZE-line shingles — duplicates preserved, so a
+ * passage copied twice counts twice toward containment (see {@link containmentScore}, which divides by the
+ * candidate's TOTAL shingle count, not its distinct count). Fewer than SHINGLE_SIZE non-trivial lines collapse
+ * to a single whole-block shingle so tiny snippets still compare (never silently score 0). */
+export function codeShingleList(lines: readonly string[]): string[] {
+ const normalized = normalizedLines(lines);
+ if (normalized.length === 0) return [];
+ if (normalized.length < SHINGLE_SIZE) return [normalized.join("\n")];
+ const shingles: string[] = [];
+ for (let i = 0; i + SHINGLE_SIZE <= normalized.length; i += 1) {
+ shingles.push(normalized.slice(i, i + SHINGLE_SIZE).join("\n"));
+ }
+ return shingles;
+}
+
+/** The DISTINCT SHINGLE_SIZE-line shingles of `lines` — a de-duplicated view of {@link codeShingleList}, used as
+ * the prior-art lookup set (membership only, so duplicates there are irrelevant). */
+export function codeShingles(lines: readonly string[]): Set {
+ return new Set(codeShingleList(lines));
+}
+
+/** Asymmetric containment (0-100): the percentage of the CANDIDATE's added-code shingles that also appear in the
+ * PRIOR ART. Unlike symmetric Jaccard, this answers "how much of THIS PR is copied FROM prior art" without
+ * being diluted by a large prior-art corpus. The candidate is a MULTISET (its total shingle count is the
+ * denominator, so a repeated copied passage is not undercounted), while the prior art is a lookup Set
+ * (membership only) — dividing by the candidate's DISTINCT count instead would undercount a passage copied
+ * more than once. 0 when either side has no comparable content. */
+export function containmentScore(candidateLines: readonly string[], priorArtLines: readonly string[]): number {
+ const candidate = codeShingleList(candidateLines);
+ if (candidate.length === 0) return 0;
+ const prior = codeShingles(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);
+}
+
+/** Parse an ISO-8601 submission time to epoch ms; null for a missing/empty/unparseable value. */
+function submissionTimeMs(value: string | null | undefined): number | null {
+ if (!value) return null;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+/** Copy direction by submission time: the EARLIER submission is the original, so the LATER one is the potential
+ * copier. Any missing/unparseable timestamp — or an exact tie — is "ambiguous" (fail-safe: never accuse). */
+export function copycatDirection(
+ candidateAt: string | null | undefined,
+ priorAt: string | null | undefined,
+): CopycatDirection {
+ const candidateMs = submissionTimeMs(candidateAt);
+ const priorMs = submissionTimeMs(priorAt);
+ if (candidateMs === null || priorMs === null) return "ambiguous";
+ if (candidateMs > priorMs) return "candidate_copied";
+ if (candidateMs < priorMs) return "candidate_is_prior";
+ return "ambiguous";
+}
+
+/** Clamp `gate.copycat.minScore` into 0-100; a non-numeric/non-finite value falls back to the engine default. */
+function normalizeMinScore(value: number | null | undefined): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_COPYCAT_MIN_SCORE;
+ return Math.min(100, Math.max(0, Math.round(value)));
+}
+
+/** Per-tier finding severity. `off` never produces a finding (see {@link assessCopycat}); it maps to `info` only
+ * so the lookup is total over {@link CopycatGateMode} without an unreachable branch. */
+const MODE_SEVERITY: Record = {
+ off: "info",
+ warn: "info",
+ label: "warning",
+ block: "critical",
+};
+
+/** Public-safe finding — reports the containment score, threshold, and the matched PR's number only (a PR
+ * number is already public on GitHub); never raw code, file names/paths, or any contributor identity. The
+ * caller may sanitize further; this text is already accusation-neutral and contains no scoring internals. */
+function buildFinding(mode: CopycatGateMode, score: number, minScore: number, matchedPullNumber: number): AdvisoryFinding {
+ return {
+ code: "copycat_overlap",
+ title: "Potential copied code detected",
+ severity: MODE_SEVERITY[mode],
+ detail: `This pull request's added code reaches ${score}% containment against prior art in #${matchedPullNumber} (threshold ${minScore}%).`,
+ action: "Confirm the overlapping code is original or properly attributed before merging.",
+ publicText: `High overlap (${score}%) with earlier prior art in #${matchedPullNumber} — please confirm originality or attribution.`,
+ };
+}
+
+/** One piece of prior art to compare the candidate PR against — an earlier open, or recently merged/closed, PR
+ * on the same repo. `pullNumber` is used only to name the match publicly (already public on GitHub); it plays
+ * no role in scoring. */
+export type CopycatPriorArtCandidate = {
+ pullNumber: number;
+ lines: readonly string[];
+ submittedAt?: string | null | undefined;
+};
+
+/** The score/direction of comparing the assessed PR against ONE prior-art candidate — one entry of
+ * {@link CopycatAssessment.matches}, kept for observability even when it doesn't clear the threshold or isn't
+ * the eligible direction. */
+export type CopycatMatch = {
+ pullNumber: number;
+ score: number;
+ direction: CopycatDirection;
+};
+
+export type CopycatAssessmentInput = {
+ /** The PR's ADDED source lines (the candidate). */
+ candidateLines: readonly string[];
+ /** ISO-8601 submission time of the candidate PR; absent/unparseable ⇒ every comparison is ambiguous. */
+ candidateSubmittedAt?: string | null | undefined;
+ /** The candidate set of prior-art PRs on the same repo to compare against (caller-fetched; may be empty). */
+ priorArt: readonly CopycatPriorArtCandidate[];
+ /** `gate.copycat.mode`; `off`/absent ⇒ never acts (scores are still computed for observability). */
+ mode?: CopycatGateMode | null | undefined;
+ /** `gate.copycat.minScore` (0-100); absent/out-of-range ⇒ {@link DEFAULT_COPYCAT_MIN_SCORE}. */
+ minScore?: number | null | undefined;
+};
+
+export type CopycatAssessment = {
+ /** The best (highest) containment score among every candidate that is unambiguously the EARLIER (prior-art)
+ * submission — i.e. only candidates the PR could actually have copied FROM. 0 when `priorArt` is empty or no
+ * candidate is unambiguously earlier. */
+ score: number;
+ /** The PR number the best score above came from, or null when there is no eligible (earlier, non-ambiguous)
+ * candidate at all. */
+ matchedPullNumber: number | null;
+ /** The resolved threshold the score was tested against. */
+ minScore: number;
+ /** True ONLY when a non-`off` mode is set AND the best eligible score >= threshold. */
+ wouldAct: boolean;
+ findings: AdvisoryFinding[];
+ /** Every candidate's own score/direction, for observability/debugging — NOT public-safe as-is (may reference
+ * PR numbers the caller hasn't otherwise disclosed); the caller decides what (if anything) beyond
+ * {@link findings} to surface. */
+ matches: CopycatMatch[];
+};
+
+/**
+ * Assess one PR's added code against a SET of candidate prior-art PRs from the same repo (#1969). Pure and
+ * precision-first: every candidate's score is always computed for observability, but only candidates that are
+ * unambiguously EARLIER than the PR (i.e. could actually be the original the PR copied from) are eligible to
+ * produce a match or a finding — a later-submitted candidate, a tie, or any missing/unparseable timestamp is
+ * excluded from consideration, so the earlier-submitted victim's own later PR is never flagged and an ambiguous
+ * comparison never accuses anyone. Among the eligible candidates, the HIGHEST score wins; a finding is emitted
+ * only when the configured mode is non-`off` and that best score clears the (resolved) threshold.
+ */
+export function assessCopycat(input: CopycatAssessmentInput): CopycatAssessment {
+ const minScore = normalizeMinScore(input.minScore);
+ const mode = input.mode ?? "off";
+
+ const matches: CopycatMatch[] = input.priorArt.map((candidate) => ({
+ pullNumber: candidate.pullNumber,
+ score: containmentScore(input.candidateLines, candidate.lines),
+ direction: copycatDirection(input.candidateSubmittedAt, candidate.submittedAt),
+ }));
+
+ let best: CopycatMatch | null = null;
+ for (const match of matches) {
+ // "candidate_copied" means OUR PR (the candidate) is the LATER submission relative to this prior-art
+ // entry — i.e. this entry is genuinely earlier, so it's eligible as the work our PR could have copied
+ // FROM. Any other direction (candidate_is_prior = WE are earlier than this entry, or ambiguous) must
+ // never be eligible — flagging either would risk accusing the victim instead of the copier.
+ if (match.direction !== "candidate_copied") continue;
+ if (best === null || match.score > best.score) best = match;
+ }
+
+ const score = best?.score ?? 0;
+ const matchedPullNumber = best?.pullNumber ?? null;
+ const wouldAct = mode !== "off" && best !== null && score >= minScore;
+
+ return {
+ score,
+ matchedPullNumber,
+ minScore,
+ wouldAct,
+ findings: wouldAct && matchedPullNumber !== null ? [buildFinding(mode, score, minScore, matchedPullNumber)] : [],
+ matches,
+ };
+}
+
+/**
+ * Re-derive whether an ALREADY-COMPUTED copycat assessment (persisted `score`/`matchedPullNumber`, e.g.
+ * PullRequestRecord.copycatScore/copycatMatchedPullNumber) would act, without re-running the (expensive,
+ * candidate-fetching) engine — for a later actuation pass that reads the persisted score back off the PR row
+ * instead of the live evaluation that originally computed it (mirrors {@link assessCopycat}'s own wouldAct
+ * logic exactly: non-`off` mode, a real match, and the score clearing the resolved threshold).
+ */
+export function copycatWouldActOnPersistedScore(
+ score: number | null | undefined,
+ matchedPullNumber: number | null | undefined,
+ mode: CopycatGateMode | null | undefined,
+ minScore: number | null | undefined,
+): boolean {
+ if ((mode ?? "off") === "off") return false;
+ if (matchedPullNumber === null || matchedPullNumber === undefined) return false;
+ if (typeof score !== "number" || !Number.isFinite(score)) return false;
+ return score >= normalizeMinScore(minScore);
+}
diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts
index 57f5ee0cf4..7f1785c91e 100644
--- a/packages/loopover-engine/src/types/manifest-deps-types.ts
+++ b/packages/loopover-engine/src/types/manifest-deps-types.ts
@@ -213,12 +213,11 @@ 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 deterministic containment engine (src/queue/copycat-detection.ts) acts on. Config-
+ * as-code only — no DB column or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. */
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` acts.
+ * 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,
@@ -541,7 +540,7 @@ export type RepositorySettings = {
/** Moderation-rules engine: a per-repo override of WHICH of the anti-abuse mechanisms (contributor cap,
* blacklist, review-nag, review-evasion) feed a contributor's shared, cross-repo violation tally.
* `undefined`/absent ⇒ inherit the global rule set (`resolveEffectiveModerationRules`'s default shape). */
- moderationRules?: ("contributor_cap" | "blacklist" | "review_nag" | "review_evasion")[] | undefined;
+ moderationRules?: ("contributor_cap" | "blacklist" | "review_nag" | "review_evasion" | "copycat")[] | undefined;
/** Moderation-rules engine: per-repo override of the label applied at >=1 lifetime violation. `undefined` ⇒
* the global config's `warningLabel` (itself defaulting to `"mod:warning"`). */
moderationWarningLabel?: string | undefined;
diff --git a/src/db/repositories.ts b/src/db/repositories.ts
index ada87ecc31..649ff46a2f 100644
--- a/src/db/repositories.ts
+++ b/src/db/repositories.ts
@@ -6198,6 +6198,8 @@ function toPullRequestRecordFromRow(row: typeof pullRequests.$inferSelect): Pull
linkedIssues: parseJson(row.linkedIssuesJson, []),
slopRisk: row.slopRisk,
slopBand: row.slopBand,
+ copycatScore: row.copycatScore,
+ copycatMatchedPullNumber: row.copycatMatchedPullNumber,
mergeAttemptCount: row.mergeAttemptCount,
mergeBlockedSha: row.mergeBlockedSha,
mergeBlockedReason: row.mergeBlockedReason,
@@ -6229,6 +6231,28 @@ export async function updatePullRequestSlopAssessment(
.where(and(eq(pullRequests.repoFullName, repoFullName), eq(pullRequests.number, pullNumber)));
}
+/**
+ * Persist or clear the latest deterministic copycat/plagiarism containment assessment (#1969) on an existing
+ * cached PR row. Same write pattern as {@link updatePullRequestSlopAssessment} — kept separate from the
+ * GitHub-sync upsert so a later sync cannot clobber the score; a no-op when the PR row does not exist yet.
+ */
+export async function updatePullRequestCopycatAssessment(
+ env: Env,
+ repoFullName: string,
+ pullNumber: number,
+ assessment: { copycatScore: number | null; copycatMatchedPullNumber: number | null },
+): Promise {
+ const db = getDb(env.DB);
+ await db
+ .update(pullRequests)
+ .set({
+ copycatScore: assessment.copycatScore,
+ copycatMatchedPullNumber: assessment.copycatMatchedPullNumber,
+ updatedAt: nowIso(),
+ })
+ .where(and(eq(pullRequests.repoFullName, repoFullName), eq(pullRequests.number, pullNumber)));
+}
+
function toIssueRecord(repoFullName: string, issue: GitHubIssuePayload): IssueRecord {
/* v8 ignore start -- GitHub REST row normalization covers sparse provider payloads at representative persistence call sites. */
return {
diff --git a/src/db/schema.ts b/src/db/schema.ts
index bf1c642d59..25d7fe97a7 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -456,6 +456,12 @@ export const pullRequests = sqliteTable(
// Latest deterministic slop assessment (loopover-computed; written separately from the GitHub sync).
slopRisk: integer("slop_risk"),
slopBand: text("slop_band"),
+ // Latest deterministic copycat/plagiarism containment assessment (#1969), same write pattern as slopRisk/
+ // slopBand above -- loopover-computed, separate from the GitHub sync. copycatMatchedPullNumber is the
+ // PR number the score was measured against, or null when no eligible (earlier, unambiguous) prior-art
+ // candidate existed for this assessment.
+ copycatScore: integer("copycat_score"),
+ copycatMatchedPullNumber: integer("copycat_matched_pull_number"),
// RC3 terminal-fail merges: failed-merge attempt count + the head SHA at which the merge is terminally
// blocked (perms/required-check/conflict) so the planner stops planning a merge. Keyed to head SHA → a new
// commit auto-clears it. loopover-computed (executor-written), omitted from the GitHub-sync SET clause.
diff --git a/src/queue/copycat-detection.ts b/src/queue/copycat-detection.ts
new file mode 100644
index 0000000000..51677cd7f8
--- /dev/null
+++ b/src/queue/copycat-detection.ts
@@ -0,0 +1,92 @@
+// Copycat/plagiarism-detection evidence collection (#1969) -- extracted alongside slop-detection.ts, the
+// duplicate-cluster/AI-slop sibling this mirrors. shouldCollectCopycatEvidence gates on settings the same way
+// shouldCollectSlopEvidence does; runCopycatAssessment fetches a BOUNDED candidate set of earlier open siblings
+// + recently-merged PRs on the same repo, extracts each candidate's added-line content, and hands the whole
+// thing to the pure containment engine (src/signals/copycat.ts).
+
+import { listPullRequestFiles, listRecentMergedPullRequests } from "../db/repositories";
+import { diffFilePriority, extractAddedLines } from "../review/review-diff";
+import { assessCopycat, type CopycatAssessment, type CopycatPriorArtCandidate } from "../signals/copycat";
+import type { PullRequestRecord, RepositorySettings } from "../types";
+
+export function shouldCollectCopycatEvidence(settings: Pick): boolean {
+ return (settings.copycatGateMode ?? "off") !== "off";
+}
+
+/** Bound on how many prior-art candidates get their full patch content fetched and scored — keeps a single
+ * gate evaluation's extra DB reads bounded regardless of repo activity, per #1969's own "bounded, fail-safe,
+ * precision-first" requirement. Open siblings (a live "someone raced my PR" case) are prioritized over
+ * historical merged PRs; the remainder of the budget goes to recently-merged candidates. */
+export const MAX_COPYCAT_CANDIDATES = 25;
+
+/** Drop generated/lockfile/vendored files from a copycat comparison, reusing review-diff.ts's own
+ * `diffFilePriority` classification (tier 4 = lockfiles/dist/build/out/coverage/vendor/node_modules) rather
+ * than inventing a second exclusion list — the same content that AI-review budgeting already treats as
+ * least-signal is exactly what #1969 asks to drop from the comparison ("reuse review.exclude_paths to drop
+ * generated/lockfile/boilerplate lines"). */
+function isComparableSourcePath(path: string): boolean {
+ return diffFilePriority(path) !== 4;
+}
+
+/** Extract one PR's comparable added-line content from its already-fetched file records: lockfile/generated/
+ * vendored files excluded, added lines from every remaining file concatenated in file order. Pure. */
+export function comparableAddedLines(
+ files: readonly { path: string; payload?: Record | null | undefined }[],
+): string[] {
+ const lines: string[] = [];
+ for (const file of files) {
+ if (!isComparableSourcePath(file.path)) continue;
+ const patch = typeof file.payload?.patch === "string" ? file.payload.patch : undefined;
+ lines.push(...extractAddedLines(patch));
+ }
+ return lines;
+}
+
+/**
+ * Fetch and score this PR's added code against a bounded candidate set of earlier open siblings + recently
+ * merged PRs on the same repo (#1969). `otherOpenPullRequests` is reused as-is (already fetched by the caller
+ * for other gate purposes — no extra query); recently-merged candidates are pre-filtered by changed-file-path
+ * overlap with the current PR (cheap — `RecentMergedPullRequestRecord.changedFiles` is already loaded) BEFORE
+ * their more expensive patch content is fetched at all, so a candidate that could not possibly overlap never
+ * costs an extra `listPullRequestFiles` read.
+ */
+export async function runCopycatAssessment(
+ env: Env,
+ args: {
+ repoFullName: string;
+ pr: { number: number; createdAt?: string | null | undefined };
+ files: Awaited>;
+ otherOpenPullRequests: readonly PullRequestRecord[];
+ mode: RepositorySettings["copycatGateMode"];
+ minScore: RepositorySettings["copycatGateMinScore"];
+ },
+): Promise {
+ const priorArt: CopycatPriorArtCandidate[] = [];
+
+ const openCandidates = args.otherOpenPullRequests.slice(0, MAX_COPYCAT_CANDIDATES);
+ for (const sibling of openCandidates) {
+ const files = await listPullRequestFiles(env, args.repoFullName, sibling.number).catch(() => []);
+ priorArt.push({ pullNumber: sibling.number, lines: comparableAddedLines(files), submittedAt: sibling.createdAt });
+ }
+
+ const remainingBudget = MAX_COPYCAT_CANDIDATES - priorArt.length;
+ if (remainingBudget > 0) {
+ const changedPathSet = new Set(args.files.map((file) => file.path));
+ const recentMerged = await listRecentMergedPullRequests(env, args.repoFullName).catch(() => []);
+ const overlapping = recentMerged
+ .filter((candidate) => candidate.changedFiles.some((path) => changedPathSet.has(path)))
+ .slice(0, remainingBudget);
+ for (const candidate of overlapping) {
+ const files = await listPullRequestFiles(env, args.repoFullName, candidate.number).catch(() => []);
+ priorArt.push({ pullNumber: candidate.number, lines: comparableAddedLines(files), submittedAt: candidate.mergedAt });
+ }
+ }
+
+ return assessCopycat({
+ candidateLines: comparableAddedLines(args.files),
+ candidateSubmittedAt: args.pr.createdAt,
+ priorArt,
+ mode: args.mode ?? "off",
+ minScore: args.minScore ?? null,
+ });
+}
diff --git a/src/queue/processors.ts b/src/queue/processors.ts
index 6de89a7f52..151cc1cdf3 100644
--- a/src/queue/processors.ts
+++ b/src/queue/processors.ts
@@ -32,6 +32,7 @@ import {
listPullRequestFiles,
listRecentMergedPullRequests,
updatePullRequestSlopAssessment,
+ updatePullRequestCopycatAssessment,
listRepoPullRequestFilePaths,
listRepoSyncStates,
listRepoSyncSegments,
@@ -369,6 +370,8 @@ export {
// `import { ... } from "../../src/queue/processors"` keeps working unchanged.
import { runAiSlopForAdvisory, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "./slop-detection";
export { runAiSlopForAdvisory, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "./slop-detection";
+import { runCopycatAssessment, shouldCollectCopycatEvidence } from "./copycat-detection";
+export { runCopycatAssessment, shouldCollectCopycatEvidence } from "./copycat-detection";
// #4013 step 5: the 5 review-evasion entry points, imported for this file's own webhook-handler call
// sites. No re-export here (unlike the shims above) -- none of these 5 functions were ever exported from
// this file, so there is no existing external `import { ... } from "../../src/queue/processors"` to keep
@@ -465,6 +468,7 @@ import {
} from "../review/unified-comment";
import { buildIssueSlopAssessment } from "../signals/issue-slop";
import { buildSlopAssessment, type SlopBand } from "../signals/slop";
+import { copycatWouldActOnPersistedScore } from "../signals/copycat";
import { buildStructuralImprovementAssessment } from "../signals/improvement";
import { runLoopOverLinkedIssueSatisfaction } from "../services/linked-issue-satisfaction-run";
import { decidePublicSurface } from "../signals/settings-preview";
@@ -2435,6 +2439,15 @@ function buildAgentMaintenancePlanInput(args: {
: {}),
// Always threaded (the DB layer populates it, default "slop"); the planner applies its own fallback.
blacklistLabel: settings.blacklistLabel,
+ // Copycat/plagiarism containment (#1969): re-derive "matched" from the PERSISTED score (written by the
+ // gate-evaluation pass that already ran the containment engine, src/queue/copycat-detection.ts — never
+ // re-run here) rather than the live per-PR score, since this actuation pass reads the re-fetched `pr` row.
+ // Absent copycatGateMode (config-as-code only, no DB column — see RepositorySettings.copycatGateMode's own
+ // doc comment) defaults to "off" the same way copycatWouldActOnPersistedScore itself does.
+ ...(copycatWouldActOnPersistedScore(pr.copycatScore, pr.copycatMatchedPullNumber, settings.copycatGateMode, settings.copycatGateMinScore) && pr.copycatMatchedPullNumber !== null && pr.copycatMatchedPullNumber !== undefined
+ ? { copycatMatch: { matched: true, score: pr.copycatScore ?? 0, matchedPullNumber: pr.copycatMatchedPullNumber } }
+ : {}),
+ copycatGateMode: settings.copycatGateMode,
...(screenshotTableMatch !== undefined ? { screenshotTableMatch } : {}),
...(contributorCapMatch !== undefined ? { contributorCapMatch } : {}),
// Always threaded (the DB layer populates it, default "over-contributor-limit"); the planner applies its
@@ -8451,7 +8464,8 @@ async function maybePublishPrPublicSurface(
null;
if (
shouldCollectSlopEvidence(settings) ||
- settings.manifestPolicyGateMode !== "off"
+ settings.manifestPolicyGateMode !== "off" ||
+ shouldCollectCopycatEvidence(settings)
) {
gateFiles = await getReviewFiles();
}
@@ -8529,6 +8543,26 @@ async function maybePublishPrPublicSurface(
}
}
}
+ // Copycat/plagiarism containment (#1969): only when opted in (copycatGateMode !== "off"). Surfaces the
+ // deterministic containment finding as advisory context (no gate-level hard block here — see
+ // planAgentMaintenanceActions's own copycat short-circuit for the label/close/strikes actuation, which
+ // reads the score persisted below rather than re-running this candidate-fetching pass). Best-effort:
+ // persistence failure never aborts gate evaluation.
+ if (shouldCollectCopycatEvidence(settings)) {
+ const copycat = await runCopycatAssessment(env, {
+ repoFullName,
+ pr: { number: pr.number, createdAt: pr.createdAt },
+ files: gateFiles ?? [],
+ otherOpenPullRequests,
+ mode: settings.copycatGateMode,
+ minScore: settings.copycatGateMinScore,
+ });
+ advisory.findings.push(...copycat.findings);
+ await updatePullRequestCopycatAssessment(env, repoFullName, pr.number, {
+ copycatScore: copycat.score,
+ copycatMatchedPullNumber: copycat.matchedPullNumber,
+ }).catch(() => undefined);
+ }
// Linked-issue satisfaction assessment (#1961/#3906, opt-in via linkedIssueSatisfactionGateMode). Assesses
// only the PR's primary linked issue -- see runLinkedIssueSatisfactionForAdvisory's own doc comment for
// the multi-linked-issue rationale. `off` (default) short-circuits before any fetch or model call, so this
diff --git a/src/review/review-diff.ts b/src/review/review-diff.ts
index ffcd791acc..73b443fbc4 100644
--- a/src/review/review-diff.ts
+++ b/src/review/review-diff.ts
@@ -36,6 +36,19 @@ export function addedLineCount(patch: string | undefined): number {
return n;
}
+/** The actual TEXT of every added (`+`) line in a patch, in order, with the leading `+` stripped — the
+ * content-level counterpart to {@link addedLineCount} (used by the copycat/plagiarism containment engine,
+ * #1969, which needs the real line text to shingle-compare, not just a count). Empty for a missing/absent
+ * patch (binary/too-large file — same "no comparable content" convention `addedLineCount` uses). */
+export function extractAddedLines(patch: string | undefined): string[] {
+ if (!patch) return [];
+ const added: string[] = [];
+ for (const line of patch.split("\n")) {
+ if (line.startsWith("+") && !line.startsWith("+++")) added.push(line.slice(1));
+ }
+ return added;
+}
+
function numericAddedLineCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts
index 2604301475..6208e3d6e6 100644
--- a/src/settings/agent-actions.ts
+++ b/src/settings/agent-actions.ts
@@ -33,6 +33,11 @@ export const DEFAULT_CONTRIBUTOR_CAP_LABEL = "over-contributor-limit";
// configurable per-repo via `.loopover.yml` (`settings.reviewNagLabel`); the planner uses the resolved label
// and falls back to this default, mirroring DEFAULT_BLACKLIST_LABEL's shape.
export const DEFAULT_REVIEW_NAG_LABEL = "review-nag-cooldown";
+// Default label applied by the copycat/plagiarism containment gate (#1969), both at its `label` tier (applied
+// standalone, PR otherwise continues through the normal pipeline) and coupled to its `block` tier's close. Same
+// configurable-with-fallback shape as DEFAULT_BLACKLIST_LABEL — a repo can override it via `.loopover.yml`
+// (`settings.copycatLabel`); this is only the fallback when unset.
+export const DEFAULT_COPYCAT_LABEL = "copycat";
// Default label applied to a PR re-closed for review-evasion (#review-evasion-protection): a contributor
// closing/converting-to-draft their own PR while an active review pass is running. NOT hardcoded -- a repo
// can override it via `.loopover.yml` (`settings.reviewEvasionLabel`); this is only the fallback when unset.
@@ -122,7 +127,7 @@ export type PlannedAgentAction = {
// mutates via the Issues API and is exempt from the PR-write-permission gate `close` must pass, so without
// this correlation a transient write-permission denial could leave a PR mislabeled "closed for X" while it
// is, in fact, still open).
- closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "review_nag" | "screenshot_table" | "heuristic";
+ closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "review_nag" | "screenshot_table" | "heuristic" | "copycat";
// For a CI-driven heuristic close, the CI state that must still hold at actuation time. Other heuristic
// closes (gate verdict, duplicate/slop, conflict) do not depend on red CI and must not be blocked by green CI.
// ALWAYS set for a heuristic close (never omitted) -- see the field's doc comment on AgentPendingActionParams
@@ -340,6 +345,26 @@ export type AgentActionPlanInput = {
// Absent ⇒ the default (`DEFAULT_REVIEW_NAG_LABEL` = "review-nag-cooldown"); explicit `null` ⇒ close WITHOUT
// any label (#label-scoping). Gated on `close` autonomy, NOT `label` — same shape as {@link blacklistLabel}.
reviewNagLabel?: string | null | undefined;
+ // Copycat/plagiarism containment (#1969): the deterministic containment engine (src/queue/copycat-detection.ts)
+ // has already scored this PR against its candidate prior-art set before this input was built — `matched: true`
+ // means the best-scoring candidate cleared the configured threshold with an unambiguous (earlier-submission)
+ // direction. Unlike blacklistMatch/contributorCapMatch/reviewNagMatch, this alone does NOT short-circuit —
+ // whether it does depends on `copycatGateMode` (see the block below): `block` short-circuits exactly like
+ // those three; `label` only adds a standalone label (maybePlanCopycatLabel) and lets the normal pipeline
+ // continue; `warn`/`off` never reach here with matched:true at all (the caller only sets this when the
+ // engine's own wouldAct, which already requires a non-off mode, was true). `matchedPullNumber` is a PR
+ // number — already public on GitHub — so it, unlike blacklistMatch's private reason, is safe to interpolate
+ // into the public close/label reason text.
+ copycatMatch?: { matched: boolean; score: number; matchedPullNumber: number } | undefined;
+ // `gate.copycat.mode` resolved for this repo (off/warn/label/block); selects which of the two copycat
+ // behaviors above (if either) applies. Absent ⇒ "off" (no effect), matching every other gate mode's
+ // absent-means-off convention.
+ copycatGateMode?: "off" | "warn" | "label" | "block" | undefined;
+ // The repo-configured label applied by the copycat gate (#1969), resolved from `.loopover.yml`. Absent ⇒ the
+ // default (`DEFAULT_COPYCAT_LABEL` = "copycat"); explicit `null` ⇒ act WITHOUT any label (#label-scoping).
+ // At `block` tier this rides `close` autonomy (inseparable metadata on the close, like blacklistLabel); at
+ // `label` tier it rides its own `label` autonomy (see maybePlanCopycatLabel).
+ copycatLabel?: string | null | undefined;
// Flag-then-close double-check for the linked-issue hard rule (#linked-issue-verify-before-close). When
// `verifyBeforeClose` is true (the default), a violation FLAGS the PR (pending-closure label + warning comment)
// on first detection and only CLOSES on a LATER evaluation when the violation STILL holds AND the PR already
@@ -585,6 +610,14 @@ function reviewNagCloseMessage(authorLogin: string, pingCount: number, maxPings:
return `LoopOver closed this because @${authorLogin} pinged @loopover ${pingCount} times, above this repository's configured limit of ${maxPings}. Please wait for the cooldown window to pass before requesting review again. This is an automated maintenance action.`;
}
+// The close comment for the copycat/plagiarism containment gate's `block` tier (#1969). `matchedPullNumber` is a
+// PR number — already public on GitHub — so, unlike blacklistCloseMessage's deliberately-static text, it's safe
+// to interpolate; the raw containment score/threshold are NOT interpolated (private scoring internals stay off
+// the public surface, matching this repo's public-safe-comment convention).
+function copycatCloseMessage(matchedPullNumber: number): string {
+ return `LoopOver is closing this pull request on the maintainer's behalf. Its added code overlaps prior art already submitted in #${matchedPullNumber} above this repository's configured threshold. This is an automated maintenance action — to pursue original work, please open a new pull request.`;
+}
+
// The close comment for the screenshot-table gate (#2006). `reason` is the repo-configured (or built-in
// default) templated contract message — already public-safe by construction (it is either the maintainer's own
// configured `.loopover.yml` text or the static DEFAULT_SCREENSHOT_CONTRACT_MESSAGE, never AI/user-derived),
@@ -627,6 +660,29 @@ function maybePlanAssign(actions: PlannedAgentAction[], input: AgentActionPlanIn
});
}
+/**
+ * Plan the copycat/plagiarism containment gate's `label` tier (#1969): unlike the `block` tier's short-circuit
+ * above, `label` mode does NOT stop merit/CI/AI analysis — it only flags the PR with a label for a human to
+ * look at while the normal pipeline continues untouched, so it rides its own `label` autonomy class (not
+ * coupled to `close`) and is applied independently, like maybePlanAssign, regardless of CI/merge state.
+ */
+function maybePlanCopycatLabel(actions: PlannedAgentAction[], input: AgentActionPlanInput): void {
+ if (input.copycatGateMode !== "label" || input.copycatMatch?.matched !== true) return;
+ if (input.authorIsOwner || input.authorIsAdmin || input.authorIsAutomationBot) return;
+ const label = resolveNullableLabel(input.copycatLabel, DEFAULT_COPYCAT_LABEL);
+ if (label === null) return;
+ const level = resolveAutonomy(input.autonomy, "label");
+ if (!isActingAutonomyLevel(level)) return;
+ actions.push({
+ actionClass: "label",
+ closeKind: "copycat",
+ requiresApproval: autonomyRequiresApproval(level),
+ reason: "copycat containment above threshold",
+ label,
+ labelOp: "add",
+ });
+}
+
/**
* Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and
* request-changes; never both merge and close), each entry already filtered to an acting autonomy class.
@@ -727,6 +783,31 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
return actions;
}
+ // Copycat/plagiarism containment, `block` tier only (#1969): same zero-hallucination short-circuit shape as
+ // blacklist/contributor-cap/review-nag above — the containment engine already resolved "matched" precision-
+ // first (unambiguous earlier-submission direction + score above threshold) before this input was built, so a
+ // match at `block` mode fires ahead of ALL merit/CI/AI analysis, tagged `closeKind: "copycat"` so it feeds the
+ // moderation-rules strike ledger the same way blacklist/contributor_cap/review_nag do. `label` mode does NOT
+ // short-circuit here — see maybePlanCopycatLabel below, called from the normal (non-short-circuiting) part of
+ // the pipeline instead.
+ const copycatContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot;
+ if (input.copycatMatch?.matched === true && copycatContributor && input.copycatGateMode === "block") {
+ const label = resolveNullableLabel(input.copycatLabel, DEFAULT_COPYCAT_LABEL);
+ if (acting("close")) {
+ actions.push({
+ actionClass: "close",
+ requiresApproval: approval("close"),
+ reason: "copycat containment above threshold",
+ closeReasons: ["copycat containment above threshold"],
+ closeComment: sanitizePublicComment(copycatCloseMessage(input.copycatMatch.matchedPullNumber)),
+ closeKind: "copycat",
+ ...(input.pr.headSha ? { expectedHeadSha: input.pr.headSha } : {}),
+ });
+ }
+ if (acting("close") && label !== null) actions.push({ actionClass: "label", autonomyClass: "close", closeKind: "copycat", requiresApproval: approval("close"), reason: "copycat containment above threshold", label, labelOp: "add" });
+ return actions;
+ }
+
// Screenshot-table gate (#2006): same zero-hallucination short-circuit shape as the blacklist above — fires
// ahead of ALL merit/CI/AI analysis, for a CONTRIBUTOR only. The trigger has already resolved scope (label/
// path match) and run the deterministic body/diff-OR-bot-capture check (#4110) before ever setting this
@@ -761,6 +842,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// check is still pending. Every deterministic no-review short-circuit above (blacklist/cap/review-nag) already
// returned before reaching this line, so none of them are affected.
maybePlanAssign(actions, input);
+ maybePlanCopycatLabel(actions, input);
// CI state over ALL of the PR's checks (required OR not — codecov/patch included) — reviewbot's ci_red
// parity. A red CI is NEVER approved/merged and is itself a close-worthy signal (non-owner). While CI is
diff --git a/src/signals/copycat.ts b/src/signals/copycat.ts
new file mode 100644
index 0000000000..fded8a9945
--- /dev/null
+++ b/src/signals/copycat.ts
@@ -0,0 +1,21 @@
+// Copycat/plagiarism-assessment shim (#1969). The canonical implementation lives at
+// packages/loopover-engine/src/signals/copycat.ts, matching the extraction pattern already used for the
+// deterministic slop scorer (src/signals/slop.ts) — imported via relative source path, not the published
+// package, to match this repo's existing engine-consumption convention (see e.g. src/signals/test-evidence.ts)
+// and to avoid depending on the engine package's built dist/ output, which is not guaranteed to exist yet when
+// typecheck/test:coverage run in CI. Keeping this file to nothing but the re-export below is what makes
+// scripts/check-engine-parity.ts recognize it as a shim rather than a hand-duplicated twin.
+export {
+ DEFAULT_COPYCAT_MIN_SCORE,
+ assessCopycat,
+ codeShingleList,
+ codeShingles,
+ containmentScore,
+ copycatDirection,
+ copycatWouldActOnPersistedScore,
+ type CopycatAssessment,
+ type CopycatAssessmentInput,
+ type CopycatDirection,
+ type CopycatMatch,
+ type CopycatPriorArtCandidate,
+} from "../../packages/loopover-engine/src/signals/copycat";
diff --git a/src/types.ts b/src/types.ts
index be3f73bae9..4784343b2a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -542,6 +542,11 @@ export type PullRequestRecord = {
* the repo opted into slop. `null`/absent = not assessed (slop off, or PR not yet processed). */
slopRisk?: number | null | undefined;
slopBand?: string | null | undefined;
+ /** Latest deterministic copycat/plagiarism containment assessment (0-100) and matched prior-art PR number
+ * (#1969), persisted the same way as slopRisk/slopBand above. `null`/absent = not assessed (copycat off,
+ * no eligible prior-art candidate, or PR not yet processed). */
+ copycatScore?: number | null | undefined;
+ copycatMatchedPullNumber?: number | null | undefined;
/** RC3 terminal-fail merges: failed auto-merge attempt count, and the head SHA at which the merge is
* terminally blocked (with a human-readable reason). When mergeBlockedSha === headSha the planner suppresses
* the `merge` disposition (held for a human); a new commit clears the block. */
@@ -615,8 +620,10 @@ 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. */
+ * separate escalation action reusing the existing moderation-rules violation ledger -- a `block`-tier close
+ * is tagged `closeKind: "copycat"`, counted the same way as blacklist/contributor_cap/review_nag, escalating
+ * to the global contributor blacklist once the ban threshold is reached -- not a 5th mode value). See
+ * {@link RepositorySettings.copycatGateMode}'s doc comment for how each tier acts. */
export type CopycatGateMode = "off" | "warn" | "label" | "block";
/** Review-check publish surface (#2852). Controls ONLY whether/how the "LoopOver Orb Review Agent" check-run
@@ -783,15 +790,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 deterministic containment engine (src/queue/copycat-detection.ts, evaluated in
+ * src/queue/processors.ts alongside slop) acts on: `warn` surfaces an advisory finding only; `label` also
+ * applies a label (src/settings/agent-actions.ts's maybePlanCopycatLabel); `block` also closes the PR
+ * (closeKind: "copycat") and counts toward the moderation-rules strikes ledger. Config-as-code only — no
+ * DB column or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. */
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
- * only, alongside {@link copycatGateMode}. */
+ /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` acts.
+ * `null`/absent ⇒ the engine's own default threshold (85). 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,
@@ -1164,7 +1171,7 @@ export type RepositorySettings = {
/** Moderation-rules engine: a per-repo override of WHICH of the anti-abuse mechanisms (contributor cap,
* blacklist, review-nag, review-evasion) feed a contributor's shared, cross-repo violation tally.
* `undefined`/absent ⇒ inherit the global rule set (`resolveEffectiveModerationRules`'s default shape). */
- moderationRules?: ("contributor_cap" | "blacklist" | "review_nag" | "review_evasion")[] | undefined;
+ moderationRules?: ("contributor_cap" | "blacklist" | "review_nag" | "review_evasion" | "copycat")[] | undefined;
/** Moderation-rules engine: per-repo override of the label applied at >=1 lifetime violation. `undefined` ⇒
* the global config's `warningLabel` (itself defaulting to `"mod:warning"`). */
moderationWarningLabel?: string | undefined;
@@ -1451,7 +1458,7 @@ export type AgentPendingActionParams = {
// (#2127), and the actuation-time live-CI re-check (#2364) — which only applies to a heuristic close — still
// fires correctly once the row is replayed through pendingActionToPlanned, rather than silently skipping for
// a lost discriminator.
- closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "review_nag" | "screenshot_table" | "heuristic";
+ closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "review_nag" | "screenshot_table" | "heuristic" | "copycat";
// For a CI-driven heuristic close, persist the CI state that must still hold when the staged action replays
// (#2364). This is separate from closeKind because heuristic closes also cover non-CI adverse signals.
// ALWAYS set (to "failed" or "not_required") for a freshly planned heuristic close (#2478) -- never omitted --
diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts
index efb261589c..4f05629c3a 100644
--- a/test/unit/agent-actions.test.ts
+++ b/test/unit/agent-actions.test.ts
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
-import { AGENT_LABEL_CHANGES, AGENT_LABEL_MIGRATION_COLLISION, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, DEFAULT_CONTRIBUTOR_CAP_LABEL, DEFAULT_REVIEW_NAG_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions";
+import { AGENT_LABEL_CHANGES, AGENT_LABEL_MIGRATION_COLLISION, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, DEFAULT_CONTRIBUTOR_CAP_LABEL, DEFAULT_COPYCAT_LABEL, DEFAULT_REVIEW_NAG_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions";
import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules";
import { REVIEW_THREAD_BLOCKER_CODE } from "../../src/review/review-thread-findings";
import type { GateCheckConclusion } from "../../src/rules/advisory";
@@ -1903,6 +1903,123 @@ describe("contributor blacklist short-circuit (#1425)", () => {
});
});
+describe("copycat/plagiarism containment gate (#1969)", () => {
+ const copycatMatched = (extra: Partial = {}) =>
+ input({
+ conclusion: "success",
+ autonomy: { close: "auto", label: "auto", approve: "auto", merge: "auto" },
+ copycatMatch: { matched: true, score: 92, matchedPullNumber: 42 },
+ copycatGateMode: "block",
+ ...extra,
+ });
+
+ it("block tier: closes + labels, winning over a passing gate (no merit review / merge) — same shape as blacklist", () => {
+ const plan = planAgentMaintenanceActions(copycatMatched());
+ expect(classes(plan)).toEqual(["close", "label"]); // short-circuit: no approve/merge despite a SUCCESS gate
+ expect(plan[0]).toMatchObject({ actionClass: "close", closeKind: "copycat" });
+ expect(plan[0]?.closeReasons).toEqual(["copycat containment above threshold"]);
+ expect(plan[1]).toMatchObject({ actionClass: "label", label: DEFAULT_COPYCAT_LABEL, labelOp: "add", closeKind: "copycat", autonomyClass: "close" });
+ });
+
+ it("block tier: names the matched (public) PR number in the close comment, never the raw score", () => {
+ const plan = planAgentMaintenanceActions(copycatMatched({ copycatMatch: { matched: true, score: 97, matchedPullNumber: 777 } }));
+ expect(plan[0]?.closeComment).toContain("#777");
+ expect(plan[0]?.closeComment).not.toContain("97");
+ });
+
+ it("block tier: pins the close to the reviewed head, mirroring blacklist (#2452)", () => {
+ const plan = planAgentMaintenanceActions(copycatMatched({ pr: { labels: [], headSha: "h-reviewed" } }));
+ expect(plan.find((a) => a.actionClass === "close")).toMatchObject({ closeKind: "copycat", expectedHeadSha: "h-reviewed" });
+ });
+
+ it("block tier: omits expectedHeadSha when the PR record has no headSha (defensive fallback)", () => {
+ const plan = planAgentMaintenanceActions(copycatMatched());
+ expect(plan.find((a) => a.actionClass === "close")?.expectedHeadSha).toBeUndefined();
+ });
+
+ it("block tier: an explicit null copycatLabel closes WITHOUT any label", () => {
+ const withLabel = planAgentMaintenanceActions(copycatMatched());
+ expect(classes(withLabel)).toEqual(["close", "label"]);
+ const withoutLabel = planAgentMaintenanceActions(copycatMatched({ copycatLabel: null }));
+ expect(classes(withoutLabel)).toEqual(["close"]);
+ });
+
+ it("block tier: the label rides on `close` autonomy, not `label` — `label: auto` alone plans nothing, `close: auto` alone plans both", () => {
+ expect(planAgentMaintenanceActions(copycatMatched({ autonomy: {} }))).toEqual([]);
+ expect(planAgentMaintenanceActions(copycatMatched({ autonomy: { label: "auto" } }))).toEqual([]);
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ autonomy: { close: "auto" } })))).toEqual(["close", "label"]);
+ });
+
+ it("block tier: fires AHEAD of CI — closes even while CI is still pending (not the pending early-return)", () => {
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ ciState: "pending" })))).toEqual(["close", "label"]);
+ });
+
+ it("block tier: NEVER fires for the owner, an admin login, or an automation bot (standing rule)", () => {
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ authorIsOwner: true })))).not.toContain("close");
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ authorIsAdmin: true })))).not.toContain("close");
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ authorIsAutomationBot: true })))).not.toContain("close");
+ });
+
+ it("block tier: no-ops when copycatMatch.matched is false (normal disposition runs)", () => {
+ expect(classes(planAgentMaintenanceActions(copycatMatched({ copycatMatch: { matched: false, score: 0, matchedPullNumber: 42 } })))).not.toContain("close");
+ });
+
+ it("label tier: adds a standalone label but does NOT short-circuit — merit/CI analysis still runs (approve/merge still reachable)", () => {
+ const plan = planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", pr: { labels: [], reviewDecision: "APPROVED" } }));
+ const labelAction = plan.find((a) => a.actionClass === "label" && a.closeKind === "copycat");
+ expect(labelAction).toMatchObject({ label: DEFAULT_COPYCAT_LABEL, labelOp: "add", requiresApproval: false });
+ // No close was planned for the copycat reason — the label-tier match never reaches the block-tier branch.
+ expect(plan.some((a) => a.actionClass === "close" && a.closeKind === "copycat")).toBe(false);
+ });
+
+ it("label tier: uses its OWN `label` autonomy, not `close` — `close: auto` alone (no `label`) plans nothing", () => {
+ expect(
+ planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", autonomy: { close: "auto" } })).some(
+ (a) => a.closeKind === "copycat",
+ ),
+ ).toBe(false);
+ expect(
+ planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", autonomy: { label: "auto" } })).some(
+ (a) => a.actionClass === "label" && a.closeKind === "copycat",
+ ),
+ ).toBe(true);
+ });
+
+ it("label tier: an explicit null copycatLabel means no label at all", () => {
+ expect(
+ planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", copycatLabel: null })).some((a) => a.closeKind === "copycat"),
+ ).toBe(false);
+ });
+
+ it("label tier: NEVER fires for the owner, an admin login, or an automation bot", () => {
+ for (const flag of ["authorIsOwner", "authorIsAdmin", "authorIsAutomationBot"] as const) {
+ expect(
+ planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", [flag]: true })).some((a) => a.closeKind === "copycat"),
+ ).toBe(false);
+ }
+ });
+
+ it("label tier: no-ops when copycatMatch is absent or unmatched", () => {
+ expect(planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", copycatMatch: undefined })).some((a) => a.closeKind === "copycat")).toBe(false);
+ expect(
+ planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "label", copycatMatch: { matched: false, score: 0, matchedPullNumber: 42 } })).some(
+ (a) => a.closeKind === "copycat",
+ ),
+ ).toBe(false);
+ });
+
+ it("warn tier and absent mode never act at all (advisory-only; no label, no close)", () => {
+ expect(planAgentMaintenanceActions(copycatMatched({ copycatGateMode: "warn" })).some((a) => a.closeKind === "copycat")).toBe(false);
+ expect(planAgentMaintenanceActions(copycatMatched({ copycatGateMode: undefined })).some((a) => a.closeKind === "copycat")).toBe(false);
+ });
+
+ it("uses the repo-configured copycatLabel at block tier, defaulting to 'copycat' when unset", () => {
+ expect(DEFAULT_COPYCAT_LABEL).toBe("copycat");
+ expect(planAgentMaintenanceActions(copycatMatched({ copycatLabel: "plagiarism-flag" }))[1]).toMatchObject({ label: "plagiarism-flag" });
+ expect(planAgentMaintenanceActions(copycatMatched())[1]).toMatchObject({ label: "copycat" });
+ });
+});
+
describe("per-contributor open-item cap short-circuit (#2270)", () => {
const overCap = (extra: Partial = {}) =>
// #label-scoping: the cap label rides on `close` autonomy, not `label` — no `label: "auto"` needed.
diff --git a/test/unit/copycat-detection.test.ts b/test/unit/copycat-detection.test.ts
new file mode 100644
index 0000000000..668d8528de
--- /dev/null
+++ b/test/unit/copycat-detection.test.ts
@@ -0,0 +1,211 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import * as repositoriesModule from "../../src/db/repositories";
+import { upsertPullRequestFile, upsertRecentMergedPullRequest } from "../../src/db/repositories";
+import { comparableAddedLines, MAX_COPYCAT_CANDIDATES, runCopycatAssessment, shouldCollectCopycatEvidence } from "../../src/queue/copycat-detection";
+import type { PullRequestFileRecord, PullRequestRecord, RecentMergedPullRequestRecord } from "../../src/types";
+import { createTestEnv } from "../helpers/d1";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+const REPO = "acme/widgets";
+
+function file(pullNumber: number, path: string, patch: string): PullRequestFileRecord {
+ return { repoFullName: REPO, pullNumber, path, status: "modified", additions: 1, deletions: 0, changes: 1, payload: { patch } };
+}
+
+function openSibling(number: number, createdAt: string): PullRequestRecord {
+ return { repoFullName: REPO, number, title: "sibling", state: "open", labels: [], linkedIssues: [], createdAt };
+}
+
+function recentMerged(number: number, mergedAt: string, changedFiles: string[]): RecentMergedPullRequestRecord {
+ return { repoFullName: REPO, number, title: "merged", labels: [], linkedIssues: [], changedFiles, mergedAt, payload: {} };
+}
+
+describe("shouldCollectCopycatEvidence", () => {
+ it("is false for off/absent, true for warn/label/block", () => {
+ expect(shouldCollectCopycatEvidence({ copycatGateMode: "off" })).toBe(false);
+ expect(shouldCollectCopycatEvidence({ copycatGateMode: undefined })).toBe(false);
+ expect(shouldCollectCopycatEvidence({ copycatGateMode: "warn" })).toBe(true);
+ expect(shouldCollectCopycatEvidence({ copycatGateMode: "label" })).toBe(true);
+ expect(shouldCollectCopycatEvidence({ copycatGateMode: "block" })).toBe(true);
+ });
+});
+
+describe("comparableAddedLines", () => {
+ it("extracts added lines from source files and concatenates in file order", () => {
+ const files = [
+ { path: "src/a.ts", payload: { patch: "+one\n+two" } },
+ { path: "src/b.ts", payload: { patch: "+three" } },
+ ];
+ expect(comparableAddedLines(files)).toEqual(["one", "two", "three"]);
+ });
+
+ it("excludes lockfile/generated/vendored paths (#1969: reuse the generated/lockfile exclusion)", () => {
+ const files = [
+ { path: "package-lock.json", payload: { patch: "+lockfile content that would otherwise inflate containment" } },
+ { path: "dist/bundle.js", payload: { patch: "+built output" } },
+ { path: "src/real.ts", payload: { patch: "+real source" } },
+ ];
+ expect(comparableAddedLines(files)).toEqual(["real source"]);
+ });
+
+ it("skips a file with no patch (binary/too-large) without throwing", () => {
+ expect(comparableAddedLines([{ path: "src/a.ts", payload: null }])).toEqual([]);
+ expect(comparableAddedLines([{ path: "src/a.ts" }])).toEqual([]);
+ });
+});
+
+describe("runCopycatAssessment", () => {
+ it("falls back to mode off / engine-default minScore when settings.copycatGateMode/copycatGateMinScore are absent", async () => {
+ const env = createTestEnv();
+ const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;";
+ await upsertPullRequestFile(env, file(42, "src/math.ts", sourceLines));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/copy.ts", sourceLines)],
+ otherOpenPullRequests: [openSibling(42, "2026-06-01T00:00:00Z")],
+ mode: undefined,
+ minScore: undefined,
+ });
+ // Score is still computed for observability, but an absent mode (⇒ "off") never acts.
+ expect(result.score).toBe(100);
+ expect(result.wouldAct).toBe(false);
+ expect(result.findings).toEqual([]);
+ });
+
+ it("returns score 0 / no findings / no match when there is no prior art at all", async () => {
+ const env = createTestEnv();
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/a.ts", "+function add(a, b) {\n+const total = a + b;\n+return total;\n+}")],
+ otherOpenPullRequests: [],
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.score).toBe(0);
+ expect(result.matchedPullNumber).toBeNull();
+ expect(result.wouldAct).toBe(false);
+ expect(result.findings).toEqual([]);
+ });
+
+ it("scores this PR against an earlier open sibling's already-fetched files, producing a finding at block mode", async () => {
+ const env = createTestEnv();
+ const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;";
+ await upsertPullRequestFile(env, file(42, "src/math.ts", sourceLines));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/copy.ts", sourceLines)],
+ otherOpenPullRequests: [openSibling(42, "2026-06-01T00:00:00Z")],
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.score).toBe(100);
+ expect(result.matchedPullNumber).toBe(42);
+ expect(result.wouldAct).toBe(true);
+ expect(result.findings).toHaveLength(1);
+ expect(result.findings[0]?.publicText).toContain("#42");
+ });
+
+ it("includes a recently-merged candidate only when it shares at least one changed-file path with the current PR", async () => {
+ const env = createTestEnv();
+ const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;";
+ // Overlapping path (src/math.ts) -- eligible for comparison, high containment.
+ await upsertRecentMergedPullRequest(env, recentMerged(7, "2026-06-01T00:00:00Z", ["src/math.ts"]));
+ await upsertPullRequestFile(env, file(7, "src/math.ts", sourceLines));
+ // Non-overlapping path (src/unrelated.ts) -- must be excluded from comparison by the path pre-filter even
+ // though its content would ALSO score 100% containment if compared (proves the pre-filter, not just luck).
+ await upsertRecentMergedPullRequest(env, recentMerged(8, "2026-06-01T00:00:00Z", ["src/unrelated.ts"]));
+ await upsertPullRequestFile(env, file(8, "src/unrelated.ts", sourceLines));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/math.ts", sourceLines)],
+ otherOpenPullRequests: [],
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.matchedPullNumber).toBe(7);
+ expect(result.matches.map((m) => m.pullNumber)).toEqual([7]);
+ });
+
+ it("never acts when the only candidate is the earlier (victim) submission's own later, independent PR — direction excludes it", async () => {
+ const env = createTestEnv();
+ const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;";
+ await upsertPullRequestFile(env, file(42, "src/math.ts", sourceLines));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-01T00:00:00Z" }, // earlier than the "sibling" below
+ files: [file(100, "src/copy.ts", sourceLines)],
+ otherOpenPullRequests: [openSibling(42, "2026-06-05T00:00:00Z")], // later
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.wouldAct).toBe(false);
+ expect(result.matchedPullNumber).toBeNull();
+ });
+
+ it("caps the number of open-sibling candidates fetched at MAX_COPYCAT_CANDIDATES, leaving no budget for recently-merged candidates", async () => {
+ const env = createTestEnv();
+ // A recently-merged candidate that WOULD match (overlapping path) if the budget reached it.
+ const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;";
+ await upsertRecentMergedPullRequest(env, recentMerged(9000, "2026-06-01T00:00:00Z", ["src/math.ts"]));
+ await upsertPullRequestFile(env, file(9000, "src/math.ts", sourceLines));
+
+ // MAX_COPYCAT_CANDIDATES open siblings, none seeded with files (listPullRequestFiles returns [] for an
+ // unseeded PR — no error), so the cap is exercised without needing real content for every one of them.
+ const manyOpenSiblings = Array.from({ length: MAX_COPYCAT_CANDIDATES }, (_, i) => openSibling(i + 1, "2026-06-01T00:00:00Z"));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/math.ts", sourceLines)],
+ otherOpenPullRequests: manyOpenSiblings,
+ mode: "block",
+ minScore: null,
+ });
+ // Exactly MAX_COPYCAT_CANDIDATES matches recorded (the open siblings), the recently-merged #9000 never reached.
+ expect(result.matches).toHaveLength(MAX_COPYCAT_CANDIDATES);
+ expect(result.matches.some((m) => m.pullNumber === 9000)).toBe(false);
+ });
+
+ it("degrades a failed per-candidate file fetch to empty lines (fail-safe) instead of throwing", async () => {
+ const env = createTestEnv();
+ vi.spyOn(repositoriesModule, "listPullRequestFiles").mockRejectedValueOnce(new Error("D1 read error"));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/copy.ts", "+some content")],
+ otherOpenPullRequests: [openSibling(42, "2026-06-01T00:00:00Z")],
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.matches).toEqual([{ pullNumber: 42, score: 0, direction: "candidate_copied" }]);
+ expect(result.wouldAct).toBe(false);
+ });
+
+ it("degrades a failed recently-merged lookup to an empty candidate list (fail-safe) instead of throwing", async () => {
+ const env = createTestEnv();
+ vi.spyOn(repositoriesModule, "listRecentMergedPullRequests").mockRejectedValueOnce(new Error("D1 read error"));
+
+ const result = await runCopycatAssessment(env, {
+ repoFullName: REPO,
+ pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" },
+ files: [file(100, "src/copy.ts", "+some content")],
+ otherOpenPullRequests: [],
+ mode: "block",
+ minScore: null,
+ });
+ expect(result.matches).toEqual([]);
+ expect(result.wouldAct).toBe(false);
+ });
+});
diff --git a/test/unit/copycat.test.ts b/test/unit/copycat.test.ts
new file mode 100644
index 0000000000..2f8a692d85
--- /dev/null
+++ b/test/unit/copycat.test.ts
@@ -0,0 +1,305 @@
+import { describe, expect, it } from "vitest";
+import {
+ assessCopycat,
+ codeShingleList,
+ codeShingles,
+ containmentScore,
+ copycatDirection,
+ copycatWouldActOnPersistedScore,
+ DEFAULT_COPYCAT_MIN_SCORE,
+} from "../../src/signals/copycat";
+
+// Six distinct normalized lines → four 3-line shingles; used as a reusable "prior art" corpus.
+const BLOCK = [
+ "function add(a, b) {",
+ "const total = a + b;",
+ "logger.debug(total);",
+ "return total;",
+ "}",
+ "export default add;",
+];
+
+// First 4 BLOCK lines (2 contained shingles) + 2 novel lines (2 non-matching shingles) → 50% containment.
+const HALF_COPIED = [
+ "function add(a, b) {",
+ "const total = a + b;",
+ "logger.debug(total);",
+ "return total;",
+ "noveltyOne();",
+ "noveltyTwo();",
+];
+
+describe("codeShingles", () => {
+ it("returns an empty set when every line is blank/whitespace-only", () => {
+ expect(codeShingles(["", " ", "\t"]).size).toBe(0);
+ });
+
+ it("collapses a sub-shingle-width snippet into a single whole-block shingle", () => {
+ const shingles = codeShingles(["const x = 1;", "const y = 2;"]);
+ expect(shingles.size).toBe(1);
+ expect([...shingles][0]).toBe("const x = 1;\nconst y = 2;");
+ });
+
+ it("produces sliding 3-line shingles for a longer block", () => {
+ // 6 non-trivial lines → 6 - 3 + 1 = 4 shingles.
+ expect(codeShingles(BLOCK).size).toBe(4);
+ });
+
+ it("codeShingleList keeps duplicate shingles that codeShingles (the distinct set) collapses", () => {
+ // Two identical 3-line blocks back-to-back → shingles [ABC, BCA, CAB, ABC]: 4 in the list, 3 distinct.
+ const repeated = ["alpha();", "beta();", "gamma();", "alpha();", "beta();", "gamma();"];
+ expect(codeShingleList(repeated)).toHaveLength(4);
+ expect(codeShingles(repeated).size).toBe(3);
+ });
+
+ it("ignores blank lines and normalizes whitespace/case before shingling", () => {
+ const a = codeShingles(["Const X = 1;", "", " const y = 2; ", "const z = 3;"]);
+ const b = codeShingles(["const x = 1;", "const y = 2;", "const z = 3;"]);
+ expect([...a]).toEqual([...b]);
+ });
+});
+
+describe("containmentScore", () => {
+ it("is 0 when the candidate has no comparable content", () => {
+ expect(containmentScore(["", " "], BLOCK)).toBe(0);
+ });
+
+ it("is 0 when the prior art has no comparable content", () => {
+ expect(containmentScore(BLOCK, [])).toBe(0);
+ });
+
+ it("is 100 when every candidate shingle appears in the prior art", () => {
+ expect(containmentScore(BLOCK, BLOCK)).toBe(100);
+ });
+
+ it("is 0 when nothing overlaps", () => {
+ expect(containmentScore(["alpha();", "beta();", "gamma();"], BLOCK)).toBe(0);
+ });
+
+ it("reports the partial percentage of candidate shingles found in the prior art", () => {
+ // Candidate = the first 4 BLOCK lines (2 contained shingles) plus 2 novel lines (2 non-matching shingles)
+ // → 4 shingles total, 2 contained → 50%.
+ expect(containmentScore(HALF_COPIED, BLOCK)).toBe(50);
+ });
+
+ it("counts a repeated copied shingle as a MULTISET, not a distinct set (regression)", () => {
+ // Candidate shingles = [ABC, BCA, CAB, ABC]; prior art = {ABC}. Multiset: 2 of 4 contained → 50%.
+ // A distinct-Set denominator would undercount this as 1 of 3 → 33%.
+ const repeatedCopier = ["alpha();", "beta();", "gamma();", "alpha();", "beta();", "gamma();"];
+ const priorArt = ["alpha();", "beta();", "gamma();"];
+ expect(containmentScore(repeatedCopier, priorArt)).toBe(50);
+ });
+});
+
+describe("copycatDirection", () => {
+ it("is candidate_copied when the candidate is submitted AFTER the prior art", () => {
+ expect(copycatDirection("2026-06-02T00:00:00Z", "2026-06-01T00:00:00Z")).toBe("candidate_copied");
+ });
+
+ it("is candidate_is_prior when the candidate is submitted BEFORE the prior art", () => {
+ expect(copycatDirection("2026-06-01T00:00:00Z", "2026-06-02T00:00:00Z")).toBe("candidate_is_prior");
+ });
+
+ it("is ambiguous on an exact timestamp tie", () => {
+ expect(copycatDirection("2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z")).toBe("ambiguous");
+ });
+
+ it("is ambiguous when either timestamp is missing", () => {
+ expect(copycatDirection(null, "2026-06-01T00:00:00Z")).toBe("ambiguous");
+ expect(copycatDirection("2026-06-01T00:00:00Z", undefined)).toBe("ambiguous");
+ expect(copycatDirection("", "2026-06-01T00:00:00Z")).toBe("ambiguous");
+ });
+
+ it("is ambiguous when either timestamp is unparseable", () => {
+ expect(copycatDirection("not-a-date", "2026-06-01T00:00:00Z")).toBe("ambiguous");
+ expect(copycatDirection("2026-06-01T00:00:00Z", "nonsense")).toBe("ambiguous");
+ });
+});
+
+describe("assessCopycat", () => {
+ const laterCopier = {
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-02T00:00:00Z",
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-01T00:00:00Z" }],
+ };
+
+ it("acts (emits a finding) on a high-containment later submission with a non-off mode", () => {
+ const result = assessCopycat({ ...laterCopier, mode: "block" });
+ expect(result.score).toBe(100);
+ expect(result.matchedPullNumber).toBe(100);
+ expect(result.minScore).toBe(DEFAULT_COPYCAT_MIN_SCORE);
+ expect(result.wouldAct).toBe(true);
+ expect(result.findings).toHaveLength(1);
+ expect(result.findings[0]).toMatchObject({ code: "copycat_overlap", severity: "critical" });
+ expect(result.findings[0]?.publicText).toContain("100%");
+ expect(result.findings[0]?.publicText).toContain("#100");
+ });
+
+ it("maps each non-off tier to its severity", () => {
+ expect(assessCopycat({ ...laterCopier, mode: "warn" }).findings[0]?.severity).toBe("info");
+ expect(assessCopycat({ ...laterCopier, mode: "label" }).findings[0]?.severity).toBe("warning");
+ expect(assessCopycat({ ...laterCopier, mode: "block" }).findings[0]?.severity).toBe("critical");
+ });
+
+ it("never acts when the mode is off (or absent), but still reports the score", () => {
+ const off = assessCopycat({ ...laterCopier, mode: "off" });
+ expect(off.score).toBe(100);
+ expect(off.wouldAct).toBe(false);
+ expect(off.findings).toEqual([]);
+ const absent = assessCopycat(laterCopier);
+ expect(absent.wouldAct).toBe(false);
+ expect(absent.findings).toEqual([]);
+ });
+
+ it("never acts when the score is below the threshold (matchedPullNumber still reports the eligible candidate, for observability)", () => {
+ const result = assessCopycat({
+ candidateLines: ["alpha();", "beta();", "gamma();"],
+ candidateSubmittedAt: "2026-06-02T00:00:00Z",
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-01T00:00:00Z" }],
+ mode: "block",
+ });
+ expect(result.score).toBe(0);
+ expect(result.matchedPullNumber).toBe(100);
+ expect(result.wouldAct).toBe(false);
+ expect(result.findings).toEqual([]);
+ });
+
+ it("never acts when the only candidate is the EARLIER (victim) submission, even at 100% containment (worst failure mode: never punish the victim)", () => {
+ const result = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-01T00:00:00Z",
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-02T00:00:00Z" }],
+ mode: "block",
+ });
+ expect(result.score).toBe(0);
+ expect(result.matchedPullNumber).toBeNull();
+ expect(result.wouldAct).toBe(false);
+ expect(result.findings).toEqual([]);
+ // Observability: the candidate's own score/direction is still reported in `matches`.
+ expect(result.matches).toEqual([{ pullNumber: 100, score: 100, direction: "candidate_is_prior" }]);
+ });
+
+ it("never acts on an ambiguous-direction candidate (missing/unparseable/tied timestamp), even at 100% containment", () => {
+ const missingCandidateAt = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: null,
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-01T00:00:00Z" }],
+ mode: "block",
+ });
+ expect(missingCandidateAt.wouldAct).toBe(false);
+ expect(missingCandidateAt.matchedPullNumber).toBeNull();
+
+ const tie = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-01T00:00:00Z",
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-01T00:00:00Z" }],
+ mode: "block",
+ });
+ expect(tie.wouldAct).toBe(false);
+ expect(tie.matchedPullNumber).toBeNull();
+ });
+
+ it("picks the HIGHEST-scoring eligible candidate among several earlier prior-art PRs", () => {
+ const result = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-03T00:00:00Z",
+ priorArt: [
+ { pullNumber: 10, lines: HALF_COPIED, submittedAt: "2026-06-01T00:00:00Z" }, // 50%-ish overlap
+ { pullNumber: 20, lines: BLOCK, submittedAt: "2026-06-02T00:00:00Z" }, // 100% overlap — should win
+ { pullNumber: 30, lines: ["alpha();"], submittedAt: "2026-06-01T00:00:00Z" }, // 0% overlap
+ ],
+ mode: "block",
+ });
+ expect(result.matchedPullNumber).toBe(20);
+ expect(result.score).toBe(100);
+ expect(result.matches).toHaveLength(3);
+ });
+
+ it("ignores a higher-scoring LATER (non-eligible) candidate in favor of a lower-scoring but eligible earlier one", () => {
+ const result = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-02T00:00:00Z",
+ priorArt: [
+ // Later than the candidate (candidate_is_prior direction) — 100% overlap, but NOT eligible.
+ { pullNumber: 10, lines: BLOCK, submittedAt: "2026-06-03T00:00:00Z" },
+ // Earlier than the candidate (candidate_copied direction) — only 50% overlap, but the only eligible one.
+ { pullNumber: 20, lines: HALF_COPIED, submittedAt: "2026-06-01T00:00:00Z" },
+ ],
+ mode: "block",
+ minScore: 40,
+ });
+ expect(result.matchedPullNumber).toBe(20);
+ expect(result.score).toBe(50);
+ expect(result.matches).toEqual(
+ expect.arrayContaining([
+ { pullNumber: 10, score: 100, direction: "candidate_is_prior" },
+ { pullNumber: 20, score: 50, direction: "candidate_copied" },
+ ]),
+ );
+ });
+
+ it("reports score 0 and no match for an empty prior-art set", () => {
+ const result = assessCopycat({
+ candidateLines: BLOCK,
+ candidateSubmittedAt: "2026-06-02T00:00:00Z",
+ priorArt: [],
+ mode: "block",
+ });
+ expect(result.score).toBe(0);
+ expect(result.matchedPullNumber).toBeNull();
+ expect(result.wouldAct).toBe(false);
+ expect(result.findings).toEqual([]);
+ expect(result.matches).toEqual([]);
+ });
+
+ it("honors a custom in-range minScore and reports it back", () => {
+ // 50% containment with a 40 threshold → acts; the same score with the default 85 would not.
+ const acting = assessCopycat({
+ candidateLines: HALF_COPIED,
+ candidateSubmittedAt: "2026-06-02T00:00:00Z",
+ priorArt: [{ pullNumber: 100, lines: BLOCK, submittedAt: "2026-06-01T00:00:00Z" }],
+ mode: "label",
+ minScore: 40,
+ });
+ expect(acting.score).toBe(50);
+ expect(acting.minScore).toBe(40);
+ expect(acting.wouldAct).toBe(true);
+ });
+
+ it("clamps and rounds an out-of-range or non-numeric minScore", () => {
+ expect(assessCopycat({ ...laterCopier, mode: "off", minScore: -5 }).minScore).toBe(0);
+ expect(assessCopycat({ ...laterCopier, mode: "off", minScore: 150 }).minScore).toBe(100);
+ expect(assessCopycat({ ...laterCopier, mode: "off", minScore: 82.6 }).minScore).toBe(83);
+ expect(assessCopycat({ ...laterCopier, mode: "off", minScore: Number.NaN }).minScore).toBe(DEFAULT_COPYCAT_MIN_SCORE);
+ expect(assessCopycat({ ...laterCopier, mode: "off", minScore: null }).minScore).toBe(DEFAULT_COPYCAT_MIN_SCORE);
+ });
+});
+
+describe("copycatWouldActOnPersistedScore — re-derives wouldAct from an already-persisted score (#1969)", () => {
+ it("acts when score clears the (default) threshold with a non-off mode and a real match", () => {
+ expect(copycatWouldActOnPersistedScore(90, 42, "block", null)).toBe(true);
+ });
+
+ it("does not act when mode is off or absent", () => {
+ expect(copycatWouldActOnPersistedScore(90, 42, "off", null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(90, 42, null, null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(90, 42, undefined, null)).toBe(false);
+ });
+
+ it("does not act when there is no matched PR (null/undefined) even at a high score", () => {
+ expect(copycatWouldActOnPersistedScore(90, null, "block", null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(90, undefined, "block", null)).toBe(false);
+ });
+
+ it("does not act when the score is missing, non-numeric, or below the threshold", () => {
+ expect(copycatWouldActOnPersistedScore(null, 42, "block", null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(undefined, 42, "block", null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(Number.NaN, 42, "block", null)).toBe(false);
+ expect(copycatWouldActOnPersistedScore(50, 42, "block", null)).toBe(false);
+ });
+
+ it("honors a custom minScore", () => {
+ expect(copycatWouldActOnPersistedScore(50, 42, "label", 40)).toBe(true);
+ expect(copycatWouldActOnPersistedScore(50, 42, "label", 60)).toBe(false);
+ });
+});
diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts
index b2bb87148c..7a66f8c0fe 100644
--- a/test/unit/data-spine.test.ts
+++ b/test/unit/data-spine.test.ts
@@ -36,6 +36,7 @@ import {
upsertIssueFromGitHub,
upsertPullRequestFromGitHub,
updatePullRequestSlopAssessment,
+ updatePullRequestCopycatAssessment,
upsertPullRequestFile,
upsertPullRequestReview,
upsertRecentMergedPullRequest,
@@ -503,6 +504,32 @@ describe("data spine repositories", () => {
// No-op (no throw) when the PR row does not exist yet.
await expect(updatePullRequestSlopAssessment(env, "owner/sloppr", 999, { slopRisk: 5, slopBand: "low" })).resolves.toBeUndefined();
});
+
+ it("persists a per-PR copycat assessment, round-trips it via the cached record, and keeps latest-wins (#1969)", async () => {
+ const env = createTestEnv();
+ await upsertPullRequestFromGitHub(env, "owner/copycatpr", { number: 5, title: "Suspicious", state: "open", user: { login: "alice" }, labels: [], body: "x" });
+ // Unassessed by default (copycat off, no eligible prior-art candidate, or PR not yet processed).
+ expect((await getPullRequest(env, "owner/copycatpr", 5))?.copycatScore ?? null).toBeNull();
+ expect((await getPullRequest(env, "owner/copycatpr", 5))?.copycatMatchedPullNumber ?? null).toBeNull();
+
+ await updatePullRequestCopycatAssessment(env, "owner/copycatpr", 5, { copycatScore: 92, copycatMatchedPullNumber: 42 });
+ const assessed = await getPullRequest(env, "owner/copycatpr", 5);
+ expect(assessed?.copycatScore).toBe(92);
+ expect(assessed?.copycatMatchedPullNumber).toBe(42);
+
+ // Latest assessment wins on the next run.
+ await updatePullRequestCopycatAssessment(env, "owner/copycatpr", 5, { copycatScore: 10, copycatMatchedPullNumber: 7 });
+ expect((await getPullRequest(env, "owner/copycatpr", 5))?.copycatMatchedPullNumber).toBe(7);
+
+ // Copycat-off (or no-match) processing can clear a previously persisted assessment.
+ await updatePullRequestCopycatAssessment(env, "owner/copycatpr", 5, { copycatScore: null, copycatMatchedPullNumber: null });
+ const cleared = await getPullRequest(env, "owner/copycatpr", 5);
+ expect(cleared?.copycatScore).toBeNull();
+ expect(cleared?.copycatMatchedPullNumber).toBeNull();
+
+ // No-op (no throw) when the PR row does not exist yet.
+ await expect(updatePullRequestCopycatAssessment(env, "owner/copycatpr", 999, { copycatScore: 5, copycatMatchedPullNumber: 1 })).resolves.toBeUndefined();
+ });
});
describe("listLatestSignalSnapshotsForTargets (#3202 — bulk latest-per-target lookup)", () => {
diff --git a/test/unit/moderation-rules-engine.test.ts b/test/unit/moderation-rules-engine.test.ts
index 2a6f0e0509..c8052349b8 100644
--- a/test/unit/moderation-rules-engine.test.ts
+++ b/test/unit/moderation-rules-engine.test.ts
@@ -34,6 +34,12 @@ describe("normalizeModerationRules (#selfhost-mod-engine)", () => {
expect(warnings).toEqual([]);
});
+ it("accepts copycat (#1969: the containment gate's 'strikes' tier reuses this same violation ledger)", () => {
+ const { rules, warnings } = normalizeModerationRules(["copycat"]);
+ expect(rules).toEqual(["copycat"]);
+ expect(warnings).toEqual([]);
+ });
+
it("drops unrecognized entries with a warning, keeping the valid ones", () => {
const { rules, warnings } = normalizeModerationRules(["contributor_cap", "not-a-rule", 42, null]);
expect(rules).toEqual(["contributor_cap"]);
@@ -144,4 +150,8 @@ describe("constants + event-type map (#selfhost-mod-engine)", () => {
it("review_evasion has its own namespaced event type (#review-evasion-protection)", () => {
expect(MODERATION_VIOLATION_EVENT_TYPE.review_evasion).toBe("moderation.violation.review_evasion");
});
+
+ it("copycat has its own namespaced event type (#1969)", () => {
+ expect(MODERATION_VIOLATION_EVENT_TYPE.copycat).toBe("moderation.violation.copycat");
+ });
});
diff --git a/test/unit/moderation-rules.test.ts b/test/unit/moderation-rules.test.ts
index 01da9921c7..1181d4e3d8 100644
--- a/test/unit/moderation-rules.test.ts
+++ b/test/unit/moderation-rules.test.ts
@@ -33,6 +33,12 @@ describe("normalizeModerationRules (#selfhost-mod-engine)", () => {
expect(warnings).toEqual([]);
});
+ it("accepts copycat (#1969: the containment gate's 'strikes' tier reuses this same violation ledger)", () => {
+ const { rules, warnings } = normalizeModerationRules(["copycat"]);
+ expect(rules).toEqual(["copycat"]);
+ expect(warnings).toEqual([]);
+ });
+
it("drops unrecognized entries with a warning, keeping the valid ones", () => {
const { rules, warnings } = normalizeModerationRules(["contributor_cap", "not-a-rule", 42, null]);
expect(rules).toEqual(["contributor_cap"]);
@@ -143,4 +149,8 @@ describe("constants + event-type map (#selfhost-mod-engine)", () => {
it("review_evasion has its own namespaced event type (#review-evasion-protection)", () => {
expect(MODERATION_VIOLATION_EVENT_TYPE.review_evasion).toBe("moderation.violation.review_evasion");
});
+
+ it("copycat has its own namespaced event type (#1969)", () => {
+ expect(MODERATION_VIOLATION_EVENT_TYPE.copycat).toBe("moderation.violation.copycat");
+ });
});
diff --git a/test/unit/queue-4.test.ts b/test/unit/queue-4.test.ts
index 393f8e8efc..0304d639fe 100644
--- a/test/unit/queue-4.test.ts
+++ b/test/unit/queue-4.test.ts
@@ -389,6 +389,46 @@ describe("queue processors", () => {
expect(sync?.status).toMatch(/^(complete|partial)$/);
});
+ it("agent-regate-pr (#1969): a copycat-gated regate persists the containment assessment against an earlier open sibling", async () => {
+ const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
+ await upsertInstallation(env, { action: "created", installation: { id: 9103, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } });
+ await upsertRepositoryFromGitHub(env, { name: "copycat-repo", full_name: "owner/copycat-repo", private: false, owner: { login: "owner" } }, 9103);
+ // reviewCheckMode: "required" (not just checkRunMode: "off") is needed so gateEnabled -> shouldEvaluateGate is
+ // true and maybePublishPrPublicSurface proceeds past its early return down to the copycat block (mirrors the
+ // #4603 sub-floor-defect test's settings shape in queue-2.test.ts). copycatGateMode is config-as-code ONLY
+ // (no DB column, per RepositorySettings.copycatGateMode's own doc comment) -- setting it on upsertRepositorySettings
+ // is silently ignored; it must go through the focus-manifest (.loopover.yml) loader instead, below.
+ await upsertRepositorySettings(env, { repoFullName: "owner/copycat-repo", autonomy: { close: "auto" }, gatePack: "oss-anti-slop", reviewCheckMode: "required", checkRunMode: "off", commentMode: "off", publicSurface: "off" });
+ await upsertRepoFocusManifest(env, "owner/copycat-repo", { gate: { copycat: { mode: "warn" } } });
+ // An earlier open sibling PR whose added code the new PR (below) reproduces verbatim.
+ const sourceLines = "function add(a, b) {\nconst total = a + b;\nlogger.debug(total);\nreturn total;\n}\nexport default add;";
+ const sourcePatch = sourceLines.split("\n").map((line) => `+${line}`).join("\n");
+ await upsertPullRequestFromGitHub(env, "owner/copycat-repo", { number: 20, title: "Original", state: "open", user: { login: "original-author" }, head: { sha: "orig20" }, labels: [], body: "x", created_at: "2026-05-01T00:00:00.000Z" });
+ await upsertPullRequestFile(env, { repoFullName: "owner/copycat-repo", pullNumber: 20, path: "src/math.ts", status: "added", additions: 6, deletions: 0, changes: 6, payload: { patch: sourcePatch } });
+ await upsertPullRequestFromGitHub(env, "owner/copycat-repo", { number: 21, title: "Copycat", state: "open", user: { login: "contributor" }, head: { sha: "c21" }, labels: [], body: "x", created_at: "2026-05-28T00:00:00.000Z" });
+ await upsertPullRequestFile(env, { repoFullName: "owner/copycat-repo", pullNumber: 21, path: "src/copy.ts", status: "added", additions: 6, deletions: 0, changes: 6, payload: { patch: sourcePatch } });
+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (url.includes("/pulls/21/files")) return Response.json([{ filename: "src/copy.ts", status: "added", additions: 6, deletions: 0, changes: 6, patch: sourcePatch }]);
+ if (url.endsWith("/pulls/21") && init?.method === "PATCH") return Response.json({ number: 21, state: "closed" });
+ if (url.endsWith("/pulls/21")) return Response.json({ number: 21, title: "Copycat", state: "open", user: { login: "contributor" }, head: { sha: "c21" }, labels: [], body: "x", created_at: "2026-05-28T00:00:00.000Z" });
+ if (url.includes("/commits/c21/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
+ if (url.includes("/commits/c21/status")) return Response.json({ state: "success", statuses: [] });
+ if (url.endsWith("/pulls/21/reviews") && init?.method === "POST") return Response.json({ id: 1 });
+ if (url.endsWith("/pulls/21/reviews")) return Response.json([]);
+ if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } });
+ return Response.json({});
+ });
+ vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z"));
+
+ await sweepAndDrainPerPr(env, "owner/copycat-repo");
+
+ const assessed = await getPullRequest(env, "owner/copycat-repo", 21);
+ expect(assessed?.copycatScore).toBe(100);
+ expect(assessed?.copycatMatchedPullNumber).toBe(20);
+ });
+
it("auto-maintain (#778): a repo with no acting autonomy takes no agent action", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
diff --git a/test/unit/review-diff.test.ts b/test/unit/review-diff.test.ts
index 6a9f2670de..625de14789 100644
--- a/test/unit/review-diff.test.ts
+++ b/test/unit/review-diff.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { addedLineCount, buildUnifiedReviewDiff, diffFilePriority, keepHighSignalHunks, totalAddedLineCount } from "../../src/review/review-diff";
+import { addedLineCount, buildUnifiedReviewDiff, diffFilePriority, extractAddedLines, keepHighSignalHunks, totalAddedLineCount } from "../../src/review/review-diff";
describe("diffFilePriority — source survives, noise drops first", () => {
it("ranks source(0) < tests(1) < docs(2) < lockfiles/generated(4)", () => {
@@ -57,6 +57,26 @@ describe("addedLineCount — counts +lines, ignores +++ header", () => {
});
});
+describe("extractAddedLines — the content-level counterpart to addedLineCount (#1969)", () => {
+ it("returns the text of only the substantive added lines, leading + stripped, ignoring the +++ header", () => {
+ expect(extractAddedLines("@@\n+a\n+b\n-c\n d")).toEqual(["a", "b"]);
+ expect(extractAddedLines("+++ b/file.ts\n+real")).toEqual(["real"]);
+ });
+
+ it("returns an empty array for a missing/absent patch", () => {
+ expect(extractAddedLines(undefined)).toEqual([]);
+ });
+
+ it("returns an empty array when there are no added lines at all", () => {
+ expect(extractAddedLines("@@\n-removed\n context")).toEqual([]);
+ });
+
+ it("agrees with addedLineCount's count on the same patch (regression: the two must never drift)", () => {
+ const patch = "+++ b/file.ts\n@@\n+one\n+two\n-three\n context\n+four";
+ expect(extractAddedLines(patch)).toHaveLength(addedLineCount(patch));
+ });
+});
+
describe("totalAddedLineCount — sums added lines across PR files (#2065)", () => {
it("uses GitHub additions metadata for patchless files so oversized diffs cannot bypass caps", () => {
expect(totalAddedLineCount([