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([