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");