diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 122f1cb510..e05fa8303b 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7963,6 +7963,18 @@ "gittensor", "oss-anti-slop" ] + }, + "slopGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "slopGateMinScore": { + "type": "number", + "nullable": true } }, "required": [ @@ -7977,6 +7989,7 @@ "linkedIssueGateMode", "duplicatePrGateMode", "qualityGateMode", + "slopGateMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", @@ -8543,6 +8556,18 @@ "gittensor", "oss-anti-slop" ] + }, + "slopGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "slopGateMinScore": { + "type": "number", + "nullable": true } }, "required": [ @@ -8557,6 +8582,7 @@ "linkedIssueGateMode", "duplicatePrGateMode", "qualityGateMode", + "slopGateMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", diff --git a/migrations/0033_slop_gate_settings.sql b/migrations/0033_slop_gate_settings.sql new file mode 100644 index 0000000000..778f0f9dc2 --- /dev/null +++ b/migrations/0033_slop_gate_settings.sql @@ -0,0 +1,5 @@ +-- Opt-in deterministic anti-slop gate (#530/#532). `slop_gate_mode`: off (default) | advisory (surface the +-- slop score + warnings in context) | block (also hard-block when slopRisk >= slop_gate_min_score). Default +-- 'off' preserves existing behavior for every current repo; the threshold defaults to the 'high' band (60). +ALTER TABLE repository_settings ADD COLUMN slop_gate_mode TEXT NOT NULL DEFAULT 'off'; +ALTER TABLE repository_settings ADD COLUMN slop_gate_min_score INTEGER; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 7fb0a827b5..68f9c01d26 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -398,6 +398,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise duplicatePrGateMode: "block", qualityGateMode: "advisory", qualityGateMinScore: null, + slopGateMode: "off", + slopGateMinScore: null, aiReviewMode: "off", aiReviewByok: false, aiReviewProvider: null, @@ -426,6 +428,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode), qualityGateMode: parseGateRuleMode(row.qualityGateMode), qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore), + slopGateMode: parseGateRuleMode(row.slopGateMode), + slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore), aiReviewMode: parseGateRuleMode(row.aiReviewMode), aiReviewByok: row.aiReviewByok, aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider), @@ -458,6 +462,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial this.toolResult(await this.predictGate(input)), ); + server.registerTool( + "gittensory_check_slop_risk", + { + description: + "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. No repo data needed.", + inputSchema: checkSlopRiskShape, + outputSchema: checkSlopRiskOutputSchema, + }, + async (input) => this.toolResult(await this.checkSlopRisk(input)), + ); + server.registerTool( "gittensory_pr_outcome", { @@ -1225,6 +1255,14 @@ export class GittensoryMcp { }; } + private async checkSlopRisk(input: z.infer>): Promise { + const assessment = buildSlopAssessment(input); + return { + summary: `Slop risk: ${assessment.slopRisk}/100 (${assessment.band}).`, + data: { ...assessment, rubric: SLOP_RUBRIC_MARKDOWN } as unknown as Record, + }; + } + private async predictGate(input: z.infer>): Promise { this.requireContributorAccess(input.login); const repoFullName = `${input.owner}/${input.repo}`; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index f2ae0b8c24..99d3da39c6 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -563,6 +563,8 @@ export const RepositorySettingsSchema = z duplicatePrGateMode: z.enum(["off", "advisory", "block"]), qualityGateMode: z.enum(["off", "advisory", "block"]), qualityGateMinScore: z.number().nullable().optional(), + slopGateMode: z.enum(["off", "advisory", "block"]), + slopGateMinScore: z.number().nullable().optional(), autoLabelEnabled: z.boolean(), gittensorLabel: z.string(), createMissingLabel: z.boolean(), @@ -597,6 +599,8 @@ export const RepoSettingsPreviewSchema = z duplicatePrGateMode: z.enum(["off", "advisory", "block"]), qualityGateMode: z.enum(["off", "advisory", "block"]), qualityGateMinScore: z.number().nullable().optional(), + slopGateMode: z.enum(["off", "advisory", "block"]), + slopGateMinScore: z.number().nullable().optional(), autoLabelEnabled: z.boolean(), gittensorLabel: z.string(), createMissingLabel: z.boolean(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 8250481b91..00162eb0e9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -131,6 +131,7 @@ import { PR_PANEL_RETRIGGER_MARKER, unionScopedOverlapClusters, } from "../signals/engine"; +import { buildSlopAssessment } from "../signals/slop"; import { decidePublicSurface } from "../signals/settings-preview"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { resolveEffectiveSettings } from "../signals/focus-manifest"; @@ -814,7 +815,7 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? ""); } -export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean) { +export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean, slopRisk?: number | null) { // `settings` is already the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved upstream by // resolveRepositorySettings, so the blocker modes here reflect the repo's config file directly. // The `oss-anti-slop` pack (#692) is repo-agnostic: it blocks ANY author whose PR trips an opted-in @@ -828,6 +829,9 @@ export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: n qualityGateMinScore: settings.qualityGateMinScore ?? null, aiReviewGateMode: settings.aiReviewMode, readinessScore: readinessScore ?? null, + slopGateMode: settings.slopGateMode, + slopGateMinScore: settings.slopGateMinScore ?? null, + slopRisk: slopRisk ?? null, confirmedContributor: confirmedContributorForPack, }; } @@ -1101,6 +1105,20 @@ async function maybePublishPrPublicSurface( scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length, }); + // Anti-slop (#530/#532): only when opted in (slopGateMode !== "off"). Surface the deterministic slop + // findings as advisory context, and feed the score to the gate (it only blocks under slop: block + the + // threshold). Loads files lazily so disabled repos pay nothing. + let slopRisk: number | null = null; + if (settings.slopGateMode !== "off") { + const slopFiles = await listPullRequestFiles(env, repoFullName, pr.number); + const slop = buildSlopAssessment({ + changedFiles: slopFiles.map((file) => ({ path: file.path, additions: file.additions, deletions: file.deletions })), + description: pr.body, + }); + slopRisk = slop.slopRisk; + advisory.findings.push(...slop.findings); + } + if (gateEnabled && author && !publicSurfaceSkipped && !official) { official = await getCachedOfficialMinerDetection(env, author, { targetKey: `${repoFullName}#${pr.number}`, @@ -1118,7 +1136,7 @@ async function maybePublishPrPublicSurface( // failure is caught and the gate is still finalized (never left in_progress). aiReview = await runAiReviewForAdvisory(env, { settings, advisory, repoFullName, pr, author, confirmedContributor }); - gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined; + gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, slopRisk)) : undefined; if (gateEnabled) { const gateCheckResult = await createOrUpdateGateCheckRun( env, diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index e64962a1bf..5d8cebdcb2 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -23,6 +23,11 @@ export type GateCheckPolicy = { * blocker. Defaults to advisory — AI never blocks unless the maintainer opts in. */ aiReviewGateMode?: GateRuleMode | undefined; readinessScore?: number | null | undefined; + /** When `block`, the deterministic slop score becomes a hard blocker once `slopRisk >= slopGateMinScore` + * (default threshold 60, the `high` band). Defaults to off/advisory — slop never blocks unless opted in. */ + slopGateMode?: GateRuleMode | undefined; + slopGateMinScore?: number | null | undefined; + slopRisk?: number | null | undefined; /** ONLY confirmed gittensor contributors can be hard-blocked. When explicitly `false`, the gate is * forced to a neutral (non-blocking) conclusion regardless of blockers — gittensory must never block * a non-confirmed contributor. `undefined` = the caller did not gate on contributor status. */ @@ -300,7 +305,8 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol } const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, policy)); const qualityBlocker = buildQualityGateBlocker(policy); - const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : [])]; + const slopBlocker = buildSlopGateBlocker(policy); + const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : []), ...(slopBlocker ? [slopBlocker] : [])]; // Contributor-gated: ONLY confirmed Gittensor contributors can be hard-blocked. For everyone else the // gate is neutral (non-blocking) + the minimal advisory comment — gittensory must never block a // non-confirmed contributor, regardless of what blockers fired. @@ -582,6 +588,24 @@ function buildQualityGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | nul }; } +// Default block threshold = the `high` band (60), used when a maintainer sets slop: block without a minScore. +const DEFAULT_SLOP_BLOCK_THRESHOLD = 60; + +function buildSlopGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | null { + if (gateMode(policy.slopGateMode) !== "block") return null; + const risk = normalizeScore(policy.slopRisk); + if (risk === null) return null; + const minScore = normalizeScore(policy.slopGateMinScore) ?? DEFAULT_SLOP_BLOCK_THRESHOLD; + if (risk < minScore) return null; + return { + code: "slop_risk_above_threshold", + severity: "warning", + title: "Slop risk is above the configured threshold", + detail: `The deterministic slop risk is ${risk}/100, at or above the repository threshold of ${minScore}/100.`, + action: "Reduce whitespace-only churn, add test evidence, or describe the change, then re-run the gate.", + }; +} + function gateMode(value: GateRuleMode | null | undefined): GateRuleMode { return value === "off" || value === "block" ? value : "advisory"; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index af87b312d7..8f88745c51 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -22,6 +22,8 @@ export type FocusManifestGateConfig = { duplicates: GateRuleMode | null; readinessMode: GateRuleMode | null; readinessMinScore: number | null; + slopMode: GateRuleMode | null; + slopMinScore: number | null; aiReviewMode: GateRuleMode | null; aiReviewByok: boolean | null; aiReviewProvider: "anthropic" | "openai" | null; @@ -147,6 +149,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { duplicates: null, readinessMode: null, readinessMinScore: null, + slopMode: null, + slopMinScore: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, @@ -271,6 +275,11 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) { warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`); } + const slop = record.slop; + const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? (slop as Record) : undefined; + if (slop !== undefined && slop !== null && slopRecord === undefined) { + warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`); + } const gate: FocusManifestGateConfig = { present: false, enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings), @@ -279,6 +288,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings), readinessMode: normalizeOptionalGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings), readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings), + slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings), + slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings), aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings), aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings), @@ -291,6 +302,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.duplicates !== null || gate.readinessMode !== null || gate.readinessMinScore !== null || + gate.slopMode !== null || + gate.slopMinScore !== null || gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || @@ -315,6 +328,12 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore; out.readiness = readiness; } + if (gate.slopMode !== null || gate.slopMinScore !== null) { + const slop: Record = {}; + if (gate.slopMode !== null) slop.mode = gate.slopMode; + if (gate.slopMinScore !== null) slop.minScore = gate.slopMinScore; + out.slop = slop; + } if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null) { const aiReview: Record = {}; if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode; @@ -461,6 +480,8 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.duplicates !== null) effective.duplicatePrGateMode = gate.duplicates; if (gate.readinessMode !== null) effective.qualityGateMode = gate.readinessMode; if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore; + if (gate.slopMode !== null) effective.slopGateMode = gate.slopMode; + if (gate.slopMinScore !== null) effective.slopGateMinScore = gate.slopMinScore; if (gate.aiReviewMode !== null) effective.aiReviewMode = gate.aiReviewMode; if (gate.aiReviewByok !== null) effective.aiReviewByok = gate.aiReviewByok; if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider; diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 7c04acceea..766f183cd3 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -187,6 +187,8 @@ export type RepoSettingsPreview = { duplicatePrGateMode: RepositorySettings["duplicatePrGateMode"]; qualityGateMode: RepositorySettings["qualityGateMode"]; qualityGateMinScore?: number | null | undefined; + slopGateMode: RepositorySettings["slopGateMode"]; + slopGateMinScore?: number | null | undefined; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; @@ -299,6 +301,8 @@ export function buildRepoSettingsPreview(args: { duplicatePrGateMode: settings.duplicatePrGateMode, qualityGateMode: settings.qualityGateMode, qualityGateMinScore: settings.qualityGateMinScore ?? null, + slopGateMode: settings.slopGateMode, + slopGateMinScore: settings.slopGateMinScore ?? null, autoLabelEnabled: settings.autoLabelEnabled, gittensorLabel: settings.gittensorLabel, createMissingLabel: settings.createMissingLabel, diff --git a/src/signals/slop.ts b/src/signals/slop.ts index 2678afc9db..b5a2bc82a6 100644 --- a/src/signals/slop.ts +++ b/src/signals/slop.ts @@ -15,6 +15,8 @@ export type SlopAssessmentInput = { changedFiles?: SlopChangedFile[] | undefined; tests?: string[] | undefined; testFiles?: string[] | undefined; + /** PR/branch description. An empty/whitespace description on a code change is a weak-effort signal. */ + description?: string | null | undefined; }; export type SlopAssessment = { @@ -23,9 +25,13 @@ export type SlopAssessment = { findings: SignalFinding[]; }; +// Deterministic, high-precision signals only — this score is the ONLY thing allowed to gate (block), so it +// must be false-positive-averse. Heuristic/AI "this reads low-effort" judgments stay ADVISORY elsewhere and +// never feed this score. Weights sum to 75 so the `high` band (>=60) is reachable from two strong signals. export const SLOP_WEIGHTS = { + trivialWhitespaceChurn: 30, missingTestEvidence: 30, - trivialWhitespaceChurn: 25, + emptyDescription: 15, } as const; export const SLOP_RUBRIC_MARKDOWN = [ @@ -37,8 +43,9 @@ export const SLOP_RUBRIC_MARKDOWN = [ "- `high`: 60-100", "", "Current deterministic signals:", - "- missing test evidence", "- trivial / whitespace-only churn", + "- missing test evidence", + "- empty pull request description on a code change", ].join("\n"); const MIN_CHURN_LINES = 40; @@ -46,14 +53,17 @@ const MAX_SOURCE_LINE_SHARE = 0.15; export function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment { const findings: SignalFinding[] = []; - const missingTestEvidenceFinding = buildMissingTestEvidenceFinding(input); const trivialChurnFinding = buildTrivialWhitespaceChurnFinding(input); - if (missingTestEvidenceFinding) findings.push(missingTestEvidenceFinding); + const missingTestEvidenceFinding = buildMissingTestEvidenceFinding(input); + const emptyDescriptionFinding = buildEmptyDescriptionFinding(input); if (trivialChurnFinding) findings.push(trivialChurnFinding); + if (missingTestEvidenceFinding) findings.push(missingTestEvidenceFinding); + if (emptyDescriptionFinding) findings.push(emptyDescriptionFinding); const slopRisk = clamp( - (missingTestEvidenceFinding ? SLOP_WEIGHTS.missingTestEvidence : 0) + - (trivialChurnFinding ? SLOP_WEIGHTS.trivialWhitespaceChurn : 0), + (trivialChurnFinding ? SLOP_WEIGHTS.trivialWhitespaceChurn : 0) + + (missingTestEvidenceFinding ? SLOP_WEIGHTS.missingTestEvidence : 0) + + (emptyDescriptionFinding ? SLOP_WEIGHTS.emptyDescription : 0), 0, 100, ); @@ -65,6 +75,27 @@ export function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment }; } +// Fires only when a real code change ships with an empty / whitespace-only description — a high-precision +// weak-effort signal. A non-empty description (even a terse one) never trips it, to avoid false positives. +export function buildEmptyDescriptionFinding(input: SlopAssessmentInput): SignalFinding | null { + const codePaths = (input.changedFiles ?? []).map((file) => file.path).filter(Boolean).filter(isCodeFile); + if (codePaths.length === 0) return null; + if ((input.description ?? "").trim().length > 0) return null; + + const detail = ensurePublicSafeText( + `${codePaths.length} code file(s) changed with an empty pull request description.`, + "Code changed with an empty pull request description.", + ); + return { + code: "empty_pr_description", + title: "Code change has no description", + severity: "warning", + detail, + action: "Describe what changed and why so reviewers can evaluate it.", + publicText: detail, + }; +} + export function buildMissingTestEvidenceFinding(input: SlopAssessmentInput): SignalFinding | null { const changedFiles = input.changedFiles ?? []; const changedPaths = changedFiles.map((file) => file.path).filter(Boolean); diff --git a/src/types.ts b/src/types.ts index 2f51d16252..a354f111e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -391,6 +391,12 @@ export type RepositorySettings = { duplicatePrGateMode: GateRuleMode; qualityGateMode: GateRuleMode; qualityGateMinScore?: number | null | undefined; + /** Deterministic anti-slop signal (#530/#532). `off` = no slop score; `advisory` = surface the slop + * score + warnings in context; `block` = ALSO hard-block when slopRisk >= slopGateMinScore (deterministic + * only, confirmed-contributor-gated like every blocker). Default `off` — opt-in via .gittensory.yml. */ + slopGateMode: GateRuleMode; + /** Slop-risk threshold (0-100) at/above which `slopGateMode: block` blocks. Default 60 (the `high` band). */ + slopGateMinScore?: number | null | undefined; /** AI maintainer review. `off` = no AI; `advisory` = post AI review notes only; `block` = ALSO let a * dual-model high-confidence consensus defect become a gate blocker (confirmed-contributors only, * like every other blocker). Default `off` — AI is opt-in. */ diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 7d13ded517..f586925825 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -236,12 +236,19 @@ describe("data spine repositories", () => { checkRunDetailLevel: "minimal", publicSurface: "comment_and_label", gatePack: "gittensor", + slopGateMode: "off", }); // gatePack (#692) round-trips and defaults to gittensor. await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "oss-anti-slop" }); expect((await getRepositorySettings(env, "owner/repo")).gatePack).toBe("oss-anti-slop"); await upsertRepositorySettings(env, { repoFullName: "owner/defaultpack" }); expect((await getRepositorySettings(env, "owner/defaultpack")).gatePack).toBe("gittensor"); + // slop gate (#530/#532) round-trips and defaults to off. + await upsertRepositorySettings(env, { repoFullName: "owner/sloprepo", slopGateMode: "block", slopGateMinScore: 55 }); + const slopSettings = await getRepositorySettings(env, "owner/sloprepo"); + expect(slopSettings.slopGateMode).toBe("block"); + expect(slopSettings.slopGateMinScore).toBe(55); + expect((await getRepositorySettings(env, "owner/defaultpack")).slopGateMode).toBe("off"); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); expect(await getIssue(env, "owner/repo", 404)).toBeNull(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 3e5e48996e..49889c54a9 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -400,7 +400,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], @@ -688,7 +688,19 @@ describe("parseFocusManifest gate config", () => { it("parses a full gate section including the readiness block", () => { const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }); + }); + + it("parses the gate.slop block, round-trips it, and warns on a non-mapping (#530/#532)", () => { + const m = parseFocusManifest({ gate: { slop: { mode: "block", minScore: 55 } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.slopMode).toBe("block"); + expect(m.gate.slopMinScore).toBe(55); + expect(gateConfigToJson(m.gate)).toMatchObject({ slop: { mode: "block", minScore: 55 } }); + + const bad = parseFocusManifest({ gate: { slop: "block" } }); + expect(bad.gate.slopMode).toBeNull(); + expect(bad.warnings.some((w) => /gate\.slop/.test(w))).toBe(true); }); it("parses gate.pack and ignores an unknown pack with a warning (#692)", () => { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 722a55d3b7..b9aaf2d6da 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -138,3 +138,42 @@ describe("AI consensus defect gate blocker", () => { expect(result.blockers).toEqual([]); }); }); + +describe("slop gate (#530/#532)", () => { + function cleanAdvisory(): Advisory { + return { ...missingIssueAdvisory(), findings: [] }; + } + + it("never blocks on slop in advisory/off mode, even at a high slop score", () => { + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "advisory", slopRisk: 90, slopGateMinScore: 60, confirmedContributor: true }).conclusion).toBe("success"); + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "off", slopRisk: 90, confirmedContributor: true }).conclusion).toBe("success"); + }); + + it("blocks when slop: block and slopRisk is at/above the threshold", () => { + const blocked = evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopGateMinScore: 60, slopRisk: 70, confirmedContributor: true }); + expect(blocked.conclusion).toBe("failure"); + expect(blocked.blockers.map((finding) => finding.code)).toContain("slop_risk_above_threshold"); + }); + + it("does not block when slopRisk is below the threshold", () => { + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopGateMinScore: 60, slopRisk: 40, confirmedContributor: true }).conclusion).toBe("success"); + }); + + it("defaults the block threshold to 60 when no minScore is set", () => { + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopRisk: 60, confirmedContributor: true }).conclusion).toBe("failure"); + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopRisk: 59, confirmedContributor: true }).conclusion).toBe("success"); + }); + + it("respects the confirmed-contributor gate (never blocks a non-confirmed author)", () => { + expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopGateMinScore: 60, slopRisk: 90, confirmedContributor: false }).conclusion).toBe("neutral"); + }); + + it("gateCheckPolicy threads slop settings + the live slopRisk into the policy (incl. .gittensory.yml)", () => { + const eff = resolveEffectiveSettings(settings({ slopGateMode: "off" }), parseFocusManifest({ gate: { slop: { mode: "block", minScore: 50 } } })); + expect(eff.slopGateMode).toBe("block"); + expect(eff.slopGateMinScore).toBe(50); + const blocked = evaluateGateCheck(cleanAdvisory(), gateCheckPolicy(eff, null, true, 80)); + expect(blocked.conclusion).toBe("failure"); + expect(blocked.blockers.map((finding) => finding.code)).toContain("slop_risk_above_threshold"); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index e9ebe57f5e..7533a00511 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -32,6 +32,7 @@ function settings(overrides: Partial = {}): RepositorySettin linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/mcp-check-slop-risk.test.ts b/test/unit/mcp-check-slop-risk.test.ts new file mode 100644 index 0000000000..0ab566a356 --- /dev/null +++ b/test/unit/mcp-check-slop-risk.test.ts @@ -0,0 +1,46 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-slop-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP gittensory_check_slop_risk", () => { + it("assesses slop from local diff metadata (no repo/auth needed) and returns the rubric", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_check_slop_risk", + arguments: { changedFiles: [{ path: "src/api/routes.ts", additions: 6, deletions: 1 }], description: "" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { slopRisk: number; band: string; findings: Array<{ code: string }>; rubric: string }; + expect(data.slopRisk).toBeGreaterThan(0); + expect(["low", "elevated", "high"]).toContain(data.band); + // Code change + no tests + empty description → both signals. + expect(data.findings.map((f) => f.code)).toEqual(expect.arrayContaining(["missing_test_evidence", "empty_pr_description"])); + expect(data.rubric).toContain("slop assessment rubric"); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("returns a clean assessment for a documented, tested change", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_check_slop_risk", + arguments: { + changedFiles: [{ path: "src/x.ts", additions: 20, deletions: 3 }, { path: "test/x.test.ts", additions: 15, deletions: 0 }], + description: "Adds a retry path with regression coverage.", + }, + }); + const data = result.structuredContent as { slopRisk: number; band: string }; + expect(data.slopRisk).toBe(0); + expect(data.band).toBe("clean"); + }); +}); diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index 0a2ac36c36..5ff9e0251e 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -66,6 +66,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index b4b287f3f0..4eeaabfc64 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -59,6 +59,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 069162830d..9ee2543891 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1529,6 +1529,7 @@ function repoSettings(repoFullName: string): RepositorySettings { linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 307bfbb254..7afbfaf0f4 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1617,6 +1617,7 @@ describe("v2 signal builders", () => { linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 931eee3630..49457efc9d 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -389,6 +389,7 @@ describe("world-class backend signals", () => { linkedIssueGateMode: "advisory" as const, duplicatePrGateMode: "advisory" as const, qualityGateMode: "advisory" as const, + slopGateMode: "off" as const, qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", @@ -435,6 +436,7 @@ describe("world-class backend signals", () => { linkedIssueGateMode: "advisory" as const, duplicatePrGateMode: "advisory" as const, qualityGateMode: "advisory" as const, + slopGateMode: "off" as const, qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", @@ -501,6 +503,7 @@ describe("world-class backend signals", () => { linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", @@ -588,6 +591,7 @@ describe("world-class backend signals", () => { linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", @@ -650,6 +654,7 @@ describe("world-class backend signals", () => { linkedIssueGateMode: "advisory", duplicatePrGateMode: "advisory", qualityGateMode: "advisory", + slopGateMode: "off", qualityGateMinScore: null, autoLabelEnabled: true, gittensorLabel: "gittensor", diff --git a/test/unit/slop.test.ts b/test/unit/slop.test.ts index 8b592150e5..40bcf9d2a0 100644 --- a/test/unit/slop.test.ts +++ b/test/unit/slop.test.ts @@ -24,6 +24,7 @@ describe("buildSlopAssessment", () => { it("raises missing-test-evidence slop for code-only diffs without tests", () => { const result = buildSlopAssessment({ changedFiles: [{ path: "src/registry/sync.ts", additions: 24, deletions: 2 }], + description: "Add retry-with-backoff to the registry sync client.", }); expect(result.slopRisk).toBe(SLOP_WEIGHTS.missingTestEvidence); @@ -45,6 +46,7 @@ describe("buildSlopAssessment", () => { { path: "src/widget.ts", additions: 2, deletions: 1 }, { path: "test/unit/widget.test.ts", additions: 4, deletions: 0 }, ], + description: "Documentation refresh plus a tiny widget tweak.", }); expect(result.slopRisk).toBe(SLOP_WEIGHTS.trivialWhitespaceChurn); @@ -65,6 +67,7 @@ describe("buildSlopAssessment", () => { { path: "src/registry/sync.ts", additions: 24, deletions: 2 }, { path: "test/unit/registry-sync.test.ts", additions: 18, deletions: 0 }, ], + description: "Add a retry path with regression coverage.", }), ).toEqual({ slopRisk: 0, band: "clean", findings: [] }); }); @@ -74,6 +77,7 @@ describe("buildSlopAssessment", () => { buildSlopAssessment({ changedFiles: [{ path: "src/registry/sync.ts", additions: 12, deletions: 0 }], testFiles: ["internal/cache_test.go"], + description: "Add a retry path, covered by cache_test.go.", }), ).toEqual({ slopRisk: 0, band: "clean", findings: [] }); }); @@ -85,6 +89,7 @@ describe("buildSlopAssessment", () => { { path: "src/registry/sync.ts", additions: 80, deletions: 20 }, { path: "test/unit/registry-sync.test.ts", additions: 40, deletions: 5 }, ], + description: "Substantive sync refactor with tests.", }), ).toEqual({ slopRisk: 0, band: "clean", findings: [] }); }); @@ -108,6 +113,7 @@ describe("buildSlopAssessment", () => { it("raises trivial-churn for non-code-only high-churn diffs", () => { expect( buildSlopAssessment({ + // Docs-only churn: no code files, so neither missing-test-evidence nor empty-description fires. changedFiles: [ { path: "README.md", additions: 25, deletions: 20 }, { path: "docs/guide.md", additions: 20, deletions: 15 }, @@ -115,6 +121,36 @@ describe("buildSlopAssessment", () => { }).findings.map((finding) => finding.code), ).toEqual(["trivial_whitespace_churn"]); }); + + it("raises empty-description slop only for a code change with no description", () => { + const flagged = buildSlopAssessment({ changedFiles: [{ path: "src/api/routes.ts", additions: 5, deletions: 1 }], description: "", tests: ["ok"], testFiles: ["test/x.test.ts"] }); + expect(flagged.findings.map((finding) => finding.code)).toContain("empty_pr_description"); + expect(flagged.slopRisk).toBe(SLOP_WEIGHTS.emptyDescription); + + // An omitted (undefined) description on a code change also trips it. + expect(buildSlopAssessment({ changedFiles: [{ path: "src/api/routes.ts", additions: 5, deletions: 1 }], tests: ["ok"], testFiles: ["test/x.test.ts"] }).findings.map((finding) => finding.code)).toContain("empty_pr_description"); + + // A non-empty description never trips it; docs-only with no description never trips it. + expect(buildSlopAssessment({ changedFiles: [{ path: "src/api/routes.ts", additions: 5, deletions: 1 }], description: "Adds a header.", tests: ["ok"], testFiles: ["test/x.test.ts"] }).findings).toEqual([]); + expect(buildSlopAssessment({ changedFiles: [{ path: "README.md", additions: 5, deletions: 1 }] }).findings).toEqual([]); + }); + + it("reaches the high band when multiple strong signals stack", () => { + // Code change, no tests, no description: missing-test-evidence (30) + empty-description (15) = elevated. + const elevated = buildSlopAssessment({ changedFiles: [{ path: "src/x.ts", additions: 10, deletions: 1 }], description: "" }); + expect(elevated.band).toBe("elevated"); + + // High-whitespace-churn code change + no tests + no description: 30 + 30 + 15 = 75 -> high (>=60). + const high = buildSlopAssessment({ + changedFiles: [ + { path: "src/x.ts", additions: 2, deletions: 1 }, + { path: "src/generated.snap", additions: 60, deletions: 40 }, + ], + description: "", + }); + expect(high.slopRisk).toBeGreaterThanOrEqual(60); + expect(high.band).toBe("high"); + }); }); describe("buildMissingTestEvidenceFinding", () => {