diff --git a/.gittensory.yml.example b/.gittensory.yml.example index cc45372b08..adc818c6b2 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -179,6 +179,20 @@ 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: + # 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. + mode: off + # Containment/similarity threshold at/above which mode acts, once the + # detection engine exists. Number 0–100, or null for the engine default. + minScore: null + # Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold # (engine defaults, not configurable here) gets a manual-review HOLD finding — # never a hard blocker; mode only turns this hold signal on or off. diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 2115890bb9..efb1b52ef2 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9304,6 +9304,19 @@ "advisory", "block" ] + }, + "copycatGateMode": { + "type": "string", + "enum": [ + "off", + "warn", + "label", + "block" + ] + }, + "copycatGateMinScore": { + "type": "number", + "nullable": true } }, "required": [ diff --git a/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx b/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx index a8aedc8918..70ee0a360a 100644 --- a/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx +++ b/apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx @@ -109,6 +109,13 @@ function HowReviewsWork() { warnings; block also hard-blocks at or above slopGateMinScore{" "} (engine default band 60). +
  • + 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. +
  • Merge-readiness gate (mergeReadinessGateMode, default{" "} off) — a composite readiness check. diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx index 9162c6562e..7a3df1a71c 100644 --- a/apps/gittensory-ui/src/routes/docs.tuning.tsx +++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx @@ -303,6 +303,13 @@ function Tuning() { a free advisory-only ai_slop_advisory finding — it never feeds the slop score 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.mergeReadiness — composite merge-readiness gate. Default{" "} off, no min score. diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 8bd3578ea5..101edfa043 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -192,6 +192,20 @@ 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: + # 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. + mode: off + # Containment/similarity threshold at/above which mode acts, once the + # detection engine exists. Number 0–100, or null for the engine default. + minScore: null + # Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold # (engine defaults, not configurable here) gets a manual-review HOLD finding — # never a hard blocker; mode only turns this hold signal on or off. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index a3df275b36..7cbbb5e461 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -173,8 +173,25 @@ export type FocusManifestGateConfig = { * hallucination can one-shot-close a structurally-clean PR) as an explicit, per-repo, documented * trade-off — never the default. */ aiJudgmentBlockersMode: "gate" | "advisory" | null; + /** `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 `.gittensory.yml` + * already declare intent without waiting on the detection engine. */ + 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. */ + copycatMinScore: number | null; }; +/** `gate.copycat.mode` (#1969) -- see {@link FocusManifestGateConfig.copycatMode}'s doc comment for why this + * is a dedicated enum rather than the shared `GateRuleMode`. */ +export type CopycatGateMode = "off" | "warn" | "label" | "block"; + // The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private // `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master // kill-switch (the feature never runs when its env flag is off, regardless of this block). See @@ -857,6 +874,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null, + copycatMode: null, + copycatMinScore: null, }; const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = { @@ -1142,6 +1161,11 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu if (slop !== undefined && slop !== null && slopRecord === undefined) { warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`); } + const copycat = record.copycat; + const copycatRecord = copycat !== null && typeof copycat === "object" && !Array.isArray(copycat) ? (copycat as Record) : undefined; + if (copycat !== undefined && copycat !== null && copycatRecord === undefined) { + warnings.push(`Manifest gate field "gate.copycat" must be a mapping; ignoring it.`); + } const size = record.size; const sizeRecord = size !== null && typeof size === "object" && !Array.isArray(size) ? (size as Record) : undefined; if (size !== undefined && size !== null && sizeRecord === undefined) { @@ -1189,6 +1213,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings), expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings), aiJudgmentBlockersMode: normalizeOptionalEnum(record.aiJudgmentBlockers, "gate.aiJudgmentBlockers", ["gate", "advisory"] as const, warnings), + copycatMode: normalizeOptionalEnum(copycatRecord?.mode, "gate.copycat.mode", ["off", "warn", "label", "block"] as const, warnings), + copycatMinScore: normalizeOptionalScore(copycatRecord?.minScore, "gate.copycat.minScore", warnings), }; // #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a // maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface @@ -1232,7 +1258,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null || gate.expectedCiContexts !== null || - gate.aiJudgmentBlockersMode !== null; + gate.aiJudgmentBlockersMode !== null || + gate.copycatMode !== null || + gate.copycatMinScore !== null; return gate; } @@ -1308,6 +1336,12 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { } if (gate.expectedCiContexts !== null) out.expectedCiContexts = gate.expectedCiContexts as JsonValue; if (gate.aiJudgmentBlockersMode !== null) out.aiJudgmentBlockers = gate.aiJudgmentBlockersMode; + if (gate.copycatMode !== null || gate.copycatMinScore !== null) { + const copycat: Record = {}; + if (gate.copycatMode !== null) copycat.mode = gate.copycatMode; + if (gate.copycatMinScore !== null) copycat.minScore = gate.copycatMinScore; + out.copycat = copycat; + } return out; } diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index eb158e279c..0195be3fda 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -171,6 +171,14 @@ export type RepositorySettings = { /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must have produced `claCheckRunName`. Required * 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 `.gittensory.yml gate.copycat.mode`. CURRENTLY INERT: parsed and threaded + * end-to-end, but no detection engine reads it yet. */ + 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}. */ + 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, * or a 403 from a token lacking `administration:read` — common for GitHub App installations). Merged with any diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs index d7527fd26b..4c35899dee 100644 --- a/scripts/check-docs-drift.mjs +++ b/scripts/check-docs-drift.mjs @@ -53,6 +53,7 @@ export const GATE_MODE_MANIFEST = [ { field: "duplicatePrGateMode", aliases: ["duplicatePrGateMode", "gate.duplicates"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] }, { field: "qualityGateMode", aliases: ["qualityGateMode", "gate.readiness.mode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] }, { field: "slopGateMode", aliases: ["slopGateMode", "gate.slop.mode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] }, + { field: "copycatGateMode", aliases: ["copycatGateMode", "gate.copycat.mode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] }, { field: "sizeGateMode", aliases: ["sizeGateMode", "gate.size"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] }, { field: "lockfileIntegrityGateMode", aliases: ["lockfileIntegrityGateMode", "gate.lockfileIntegrity"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] }, { field: "claGateMode", aliases: ["claGateMode", "gate.claMode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] }, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 13abeadd91..d9715803ca 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -651,6 +651,8 @@ export const RepositorySettingsSchema = z claCheckRunName: z.string().nullable().optional(), claCheckRunAppSlug: z.string().nullable().optional(), expectedCiContexts: z.array(z.string()).optional(), + copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(), + copycatGateMinScore: z.number().nullable().optional(), gateDryRun: z.boolean().optional(), premergeContentRecheck: z.boolean().optional(), requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(), diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 6b5d27f15d..2fb1c90fa6 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -469,6 +469,8 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani if (gate.claCheckRunName !== null) effective.claCheckRunName = gate.claCheckRunName; if (gate.claCheckRunAppSlug !== null) effective.claCheckRunAppSlug = gate.claCheckRunAppSlug; if (gate.expectedCiContexts !== null) effective.expectedCiContexts = gate.expectedCiContexts; + if (gate.copycatMode !== null) effective.copycatGateMode = gate.copycatMode; + if (gate.copycatMinScore !== null) effective.copycatGateMinScore = gate.copycatMinScore; } /** diff --git a/src/types.ts b/src/types.ts index 20f05e0718..bf815d585b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -586,6 +586,12 @@ export type BountyRecord = { 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. */ +export type CopycatGateMode = "off" | "warn" | "label" | "block"; + /** Review-check publish surface (#2852). Controls ONLY whether/how the "Gittensory Orb Review Agent" check-run * is created/updated -- never the underlying gate evaluation, disposition, comments, labels, audit, or * autonomous merge/close, all of which run identically in every mode (the autonomous decision engine already @@ -726,6 +732,17 @@ export type RepositorySettings = { /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must have produced `claCheckRunName`. Required * 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 `.gittensory.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/gittensory-engine for the tracked follow-up plan. */ + 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}. */ + 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, * or a 403 from a token lacking `administration:read` — common for GitHub App installations). Merged with any diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts index 78010ca3cd..4626b436f0 100644 --- a/test/unit/check-docs-drift-script.test.ts +++ b/test/unit/check-docs-drift-script.test.ts @@ -141,8 +141,8 @@ describe("check-docs-drift script", () => { const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) }); expect(result.failures).toEqual([]); - // gateModes bumped 11 -> 12 for linkedIssueSatisfactionGateMode (#1961/#3906). - expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 12 }); + // gateModes bumped 12 -> 13 for copycatGateMode (#1969, currently inert config scaffold). + expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 13 }); }); it("catches an unmapped *GateMode field missing from GATE_MODE_MANIFEST", () => { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 77335b8e5b..458e485ae5 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -274,6 +274,8 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { claCheckRunAppSlug: "checkRunAppSlug:", expectedCiContexts: "expectedCiContexts:", aiJudgmentBlockersMode: "aiJudgmentBlockers:", + copycatMode: "copycat:", + copycatMinScore: "copycat:", } satisfies Record, string>; it.each(Object.entries(GATE_FIELD_TOKENS))("documents gate.%s", (_field, token) => { @@ -800,7 +802,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null }, + gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null, grounding: null }, @@ -1110,7 +1112,7 @@ describe("parseFocusManifest gate config", () => { // the block→advisory deprecation-downgrade behavior itself is covered separately below. const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null }); + expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { @@ -1189,6 +1191,72 @@ describe("parseFocusManifest gate config", () => { expect(bad.warnings.some((w) => /gate\.slop\.aiAdvisory/.test(w))).toBe(true); }); + it("parses the gate.copycat block, round-trips it, and warns on a non-mapping (#1969)", () => { + const m = parseFocusManifest({ gate: { copycat: { mode: "block", minScore: 55 } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.copycatMode).toBe("block"); + expect(m.gate.copycatMinScore).toBe(55); + expect(gateConfigToJson(m.gate)).toMatchObject({ copycat: { mode: "block", minScore: 55 } }); + + const bad = parseFocusManifest({ gate: { copycat: "block" } }); + expect(bad.gate.copycatMode).toBeNull(); + expect(bad.warnings.some((w) => /gate\.copycat/.test(w))).toBe(true); + }); + + it("gateConfigToJson round-trips gate.copycat with only ONE of mode/minScore set (#1969)", () => { + // Each field is independently optional in the source YML, so gateConfigToJson must not assume they always + // arrive together -- mode-only and minScore-only must each serialize without the other key present. + const modeOnly = parseFocusManifest({ gate: { copycat: { mode: "label" } } }); + const modeOnlyJson = gateConfigToJson(modeOnly.gate) as Record>; + expect(modeOnlyJson).toMatchObject({ copycat: { mode: "label" } }); + expect(modeOnlyJson.copycat).not.toHaveProperty("minScore"); + + const minScoreOnly = parseFocusManifest({ gate: { copycat: { minScore: 42 } } }); + const minScoreOnlyJson = gateConfigToJson(minScoreOnly.gate) as Record>; + expect(minScoreOnlyJson).toMatchObject({ copycat: { minScore: 42 } }); + expect(minScoreOnlyJson.copycat).not.toHaveProperty("mode"); + }); + + it("accepts every gate.copycat.mode tier (off/warn/label/block) and warns on an unknown one (#1969)", () => { + for (const mode of ["off", "warn", "label", "block"] as const) { + expect(parseFocusManifest({ gate: { copycat: { mode } } }).gate.copycatMode).toBe(mode); + } + // Deliberately NOT the shared off/advisory/block scale -- "advisory" isn't a valid copycat tier. + const bad = parseFocusManifest({ gate: { copycat: { mode: "advisory" } } }); + expect(bad.gate.copycatMode).toBeNull(); + expect(bad.warnings.some((w) => /gate\.copycat\.mode/.test(w))).toBe(true); + }); + + it("clamps and rounds gate.copycat.minScore to 0-100 (#1969)", () => { + expect(parseFocusManifest({ gate: { copycat: { minScore: 250 } } }).gate.copycatMinScore).toBe(100); + expect(parseFocusManifest({ gate: { copycat: { minScore: -10 } } }).gate.copycatMinScore).toBe(0); + expect(parseFocusManifest({ gate: { copycat: { minScore: 59.6 } } }).gate.copycatMinScore).toBe(60); + const bad = parseFocusManifest({ gate: { copycat: { minScore: "high" } } }); + expect(bad.gate.copycatMinScore).toBeNull(); + expect(bad.warnings.some((w) => /gate\.copycat\.minScore/.test(w))).toBe(true); + }); + + it("gate.copycat is absent by default -- byte-identical to today when unset (#1969)", () => { + const m = parseFocusManifest({ gate: { slop: { mode: "off" } } }); + expect(m.gate.copycatMode).toBeNull(); + expect(m.gate.copycatMinScore).toBeNull(); + expect(gateConfigToJson(m.gate)).not.toHaveProperty("copycat"); + }); + + it("resolveEffectiveSettings projects gate.copycat onto copycatGateMode/copycatGateMinScore, and leaves the DB row's value alone when unset (#1969)", () => { + const m = parseFocusManifest({ gate: { copycat: { mode: "warn", minScore: 40 } } }); + const eff = resolveEffectiveSettings({} as RepositorySettings, m); + expect(eff.copycatGateMode).toBe("warn"); + expect(eff.copycatGateMinScore).toBe(40); + + // Unset in the manifest -- the DB row's own value (if any) is left untouched, same as every other + // config-as-code-only gate field's "no override" branch. + const unsetManifest = parseFocusManifest({ gate: { slop: { mode: "off" } } }); + const effUnset = resolveEffectiveSettings({ copycatGateMode: "label", copycatGateMinScore: 80 } as RepositorySettings, unsetManifest); + expect(effUnset.copycatGateMode).toBe("label"); + expect(effUnset.copycatGateMinScore).toBe(80); + }); + it("parses gate.pack and ignores an unknown pack with a warning (#692)", () => { expect(parseFocusManifest({ gate: { pack: "oss-anti-slop" } }).gate.pack).toBe("oss-anti-slop"); expect(parseFocusManifest({ gate: { pack: "gittensor" } }).gate.pack).toBe("gittensor");