diff --git a/src/api/routes.ts b/src/api/routes.ts index 7f7ffe9833..815ca93d9c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2650,6 +2650,9 @@ export function createApp() { bounties, issueQuality: issueQuality?.report, confirmedContributor: Boolean(context.gittensorSnapshot), + // #11-13/#18: thread the local branch's changed PATHS (already in the request) so the predictor also + // evaluates the focus-manifest path policy + path-gated pre-merge checks, matching the live gate. + ...(parsed.data.changedFiles ? { changedPaths: parsed.data.changedFiles.map((file) => file.path) } : {}), }); const response = { ...analysis, predictedGate, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) }; await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record, analysis.generatedAt); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a1de0e6a82..220332d5b8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -675,6 +675,9 @@ const predictGateShape = { body: z.string().optional(), labels: z.array(z.string()).optional(), linkedIssues: z.array(z.number().int().positive()).optional(), + // The PR's changed file PATHS (metadata only — paths, never source content). Supplying them lets the predictor + // also evaluate the focus-manifest path policy + path-gated pre-merge checks, matching the live gate (#11-13/#18). + changedPaths: z.array(z.string().min(1)).max(500).optional(), }; // Pure local-metadata computation (no repo data, no secrets) — the agent supplies its own diff metadata @@ -2089,6 +2092,7 @@ export class GittensoryMcp { bounties, issueQuality: issueQuality?.report, confirmedContributor, + ...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }), }); return { summary: `Predicted Gittensory gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`, diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index 3062037b2c..de0d4767fc 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -6,7 +6,7 @@ import { unionScopedOverlapClusters, type IssueQualityReport, } from "../signals/engine"; -import type { FocusManifest } from "../signals/focus-manifest"; +import { buildFocusManifestGuidance, type FocusManifest } from "../signals/focus-manifest"; import { sanitizePublicComment } from "../github/commands"; import { GITTENSOR_HOME_URL } from "../github/footer"; import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types"; @@ -17,7 +17,7 @@ const OSS_ANTI_SLOP_FUNNEL = { message: "This repo runs the Gittensor anti-slop gate. Gittensor lets GitHub contributors earn for open-source work like this — register to start earning.", registerUrl: GITTENSOR_HOME_URL, } as const; -import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory"; +import { buildPullRequestAdvisory, evaluateGateCheck, isTestPath, type GateCheckConclusion } from "./advisory"; import { evaluatePreMergeChecks } from "../review/pre-merge-checks"; /** @@ -53,14 +53,25 @@ export type PredictedGateVerdict = { note: string; }; -const PREDICTED_GATE_NOTE = +const PREDICTED_GATE_NOTE_BASE = "Predicted from the repo's public .gittensory.yml gate config + safe defaults. The maintainer may have " + "private dashboard overrides not reflected here, and the dual-model AI-consensus blocker is only " + - "evaluated on a real PR. Diff-dependent checks are NOT evaluated pre-submission and may still fail the real " + - "gate: the slop score, the focus-manifest path policy, and any pre-merge check scoped to changed paths " + - "(path-independent title/description/label pre-merge checks ARE predicted). Every author is gated the same: " + - "a configured hard blocker fails the gate regardless of confirmed-contributor status (which affects only " + - "on-chain scoring)."; + "evaluated on a real PR. "; +// The slop score is ALWAYS disclaimed: it needs the diff CONTENT, which the metadata-only oracle never receives. +const PREDICTED_GATE_NOTE_SLOP = "The slop score is NOT evaluated pre-submission (it needs the diff content) and may still fail the real gate. "; +// Disclaimed only when the caller did NOT supply changed paths — then path-dependent gates can't be predicted. +const PREDICTED_GATE_NOTE_NO_PATHS = + "Provide the PR's changed paths to also predict the focus-manifest path policy and any pre-merge check scoped " + + "to changed paths; without them only path-independent title/description/label pre-merge checks are predicted. "; +const PREDICTED_GATE_NOTE_GATE_EQUALITY = + "Every author is gated the same: a configured hard blocker fails the gate regardless of confirmed-contributor " + + "status (which affects only on-chain scoring)."; + +/** Compose the predicted-gate note. Slop is always disclaimed; the path-policy/path-gated disclaimer drops once + * the caller supplies changed paths (#11-13/#18). */ +function predictedGateNote(hasChangedPaths: boolean): string { + return PREDICTED_GATE_NOTE_BASE + PREDICTED_GATE_NOTE_SLOP + (hasChangedPaths ? "" : PREDICTED_GATE_NOTE_NO_PATHS) + PREDICTED_GATE_NOTE_GATE_EQUALITY; +} export type PredictedGateInput = { repoFullName: string; @@ -93,9 +104,16 @@ export function buildPredictedGateVerdict(args: { * it no longer changes the predicted verdict (the real gate fails any author on a configured blocker; * confirmed-status affects only on-chain scoring). `undefined` → not resolved. */ confirmedContributor?: boolean | undefined; + /** The PR's changed file PATHS (metadata only — file paths, never source content, so the predictor stays + * metadata-only). When supplied, the path-dependent gates the live gate enforces are also predicted: the + * focus-manifest path policy and the path-gated pre-merge checks. Absent ⇒ only path-independent pre-merge + * checks are predicted and the note discloses the gap (#11-13/#18). */ + changedPaths?: string[] | undefined; }): PredictedGateVerdict { const { input, manifest, repo, issues, pullRequests } = args; const gate = manifest.gate; + const changedPaths = (args.changedPaths ?? []).filter((path) => typeof path === "string" && path.length > 0); + const hasChangedPaths = changedPaths.length > 0; const preflight = buildPreflightResult( { @@ -153,15 +171,42 @@ export function buildPredictedGateVerdict(args: { const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null); const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue, linkedIssueAuthorLogins }); - // Deterministic pre-merge checks parity (#11/#18), partial: the LIVE gate enforces the repo's - // `review.pre_merge_checks` (from the SAME public .gittensory.yml the predictor already reads), but the - // predictor ignored them — so a PR the gate would auto-close on an enforced title/description rule predicted - // "success". Evaluate the PATH-INDEPENDENT checks (empty `whenPaths` — title/description/label assertions) here: - // their inputs (title/body/labels) are exactly the real PR's, so the result is identical to live. Path-gated - // checks, manifest-policy, and slop need the PR's changed files/diff (not available pre-submission) and are - // disclaimed in the note below; they reach parity once the predictor accepts changed paths. - const alwaysApplyPreMergeChecks = manifest.review.preMergeChecks.filter((check) => check.whenPaths.length === 0); - advisory.findings.push(...evaluatePreMergeChecks(alwaysApplyPreMergeChecks, { title: syntheticPr.title, body: syntheticPr.body, labels: syntheticPr.labels, changedPaths: [] })); + // Deterministic pre-merge checks parity (#11/#18): the LIVE gate enforces the repo's `review.pre_merge_checks` + // (from the SAME public .gittensory.yml the predictor already reads). With the PR's changed paths supplied, + // evaluate ALL of them exactly as live (path-gated checks now have their `whenPaths` to match against); without + // paths, evaluate only the PATH-INDEPENDENT checks (empty `whenPaths` — title/description/label assertions), + // whose inputs are exactly the real PR's, and disclaim the path-gated ones in the note. + const predictablePreMergeChecks = hasChangedPaths ? manifest.review.preMergeChecks : manifest.review.preMergeChecks.filter((check) => check.whenPaths.length === 0); + advisory.findings.push( + ...evaluatePreMergeChecks(predictablePreMergeChecks, { title: syntheticPr.title, body: syntheticPr.body, labels: syntheticPr.labels, changedPaths, filesResolved: hasChangedPaths }), + ); + + // Focus-manifest path policy parity (#12): the LIVE gate (manifestPolicyGateMode) pushes the three enforceable + // policy findings over the PR's changed paths. Mirror it when the caller supplied paths and the PUBLIC config + // opts in — recompute the guidance and append ONLY the policy codes, then thread manifestPolicyGateMode into + // evaluateGateCheck below so block-mode blocks (advisory stays a warning). Without paths, this is skipped. + if (hasChangedPaths && gate.manifestPolicy !== null && gate.manifestPolicy !== "off") { + const guidance = buildFocusManifestGuidance({ + manifest, + changedPaths, + labels: syntheticPr.labels, + linkedIssueCount: syntheticPr.linkedIssues.length, + testFileCount: changedPaths.filter((path) => isTestPath(path)).length, + passedValidationCount: 0, + }); + const policyCodes = new Set(["manifest_blocked_path", "manifest_linked_issue_required", "manifest_missing_tests"]); + for (const finding of guidance.findings) { + if (!policyCodes.has(finding.code)) continue; + advisory.findings.push({ + code: finding.code, + severity: finding.severity, + title: finding.title, + detail: finding.detail, + /* v8 ignore next -- the three policy findings always carry an action; the no-action arm is unreachable here. */ + ...(finding.action !== undefined ? { action: finding.action } : {}), + }); + } + } // Pack-aware (#693): under `oss-anti-slop` the gate blocks ANY author, so drop the confirmed-contributor // gate entirely (mirrors gateCheckPolicy). `gittensor` keeps it. Pack comes from the PUBLIC .gittensory.yml. @@ -180,6 +225,9 @@ export function buildPredictedGateVerdict(args: { qualityGateMinScore: gate.readinessMinScore ?? null, aiReviewGateMode: gate.aiReviewMode ?? undefined, mergeReadinessGateMode: gate.mergeReadiness ?? undefined, + // #12: only meaningful when changed paths were supplied (the policy findings are pushed above only then); + // absent paths ⇒ no manifest finding exists, so this mode has nothing to act on (byte-identical). + manifestPolicyGateMode: gate.manifestPolicy ?? undefined, selfAuthoredLinkedIssueGateMode: gate.selfAuthoredLinkedIssue ?? undefined, readinessScore: readiness.total, confirmedContributor: effectiveConfirmedContributor, @@ -200,6 +248,6 @@ export function buildPredictedGateVerdict(args: { blockers: evaluation.blockers.map(publicSafeFinding), warnings: evaluation.warnings.map(publicSafeFinding), funnel: pack === "oss-anti-slop" ? { ...OSS_ANTI_SLOP_FUNNEL } : null, - note: PREDICTED_GATE_NOTE, + note: predictedGateNote(hasChangedPaths), }; } diff --git a/test/unit/mcp-predict-gate.test.ts b/test/unit/mcp-predict-gate.test.ts index 342dc617ad..085f70eedb 100644 --- a/test/unit/mcp-predict-gate.test.ts +++ b/test/unit/mcp-predict-gate.test.ts @@ -46,6 +46,26 @@ describe("MCP gittensory_predict_gate", () => { expect((minimal.structuredContent as { pack: string }).pack).toBe("oss-anti-slop"); }); + it("predicts the focus-manifest path policy when changedPaths are supplied (#11-13/#18)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + // Public config: oss-anti-slop (no account needed), manifest path policy in block mode, dist/** blocked. + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", manifestPolicy: "block" }, blockedPaths: ["dist/**"] }); + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Build output", changedPaths: ["dist/bundle.js"] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { conclusion: string; blockers: Array<{ code: string }>; note: string }; + expect(data.conclusion).toBe("failure"); + expect(data.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(true); + // With paths supplied the note drops the "provide changed paths" disclaimer but still disclaims slop. + expect(data.note).not.toContain("Provide the PR's changed paths"); + expect(data.note.toLowerCase()).toContain("slop"); + }); + // Parity (#gate-nonconfirmed): every author is gated identically now — a synthetic PR that trips a blocker // predicts `failure` regardless of confirmed status, matching the real maintainer gate. The prediction still // resolves + surfaces the caller's confirmed status (transparency / on-chain scoring context) but it no diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index 93af2a46d5..dc10a9fb67 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -21,13 +21,22 @@ const BASE_INPUT: PredictedGateInput = { linkedIssues: [7], }; -function verdict(args: { gate: Record; review?: Record; input?: Partial; issues?: IssueRecord[]; pullRequests?: PullRequestRecord[] }) { +function verdict(args: { + gate: Record; + review?: Record; + manifestExtra?: Record; + changedPaths?: string[]; + input?: Partial; + issues?: IssueRecord[]; + pullRequests?: PullRequestRecord[]; +}) { return buildPredictedGateVerdict({ input: { ...BASE_INPUT, ...args.input }, - manifest: parseFocusManifest({ gate: args.gate, ...(args.review ? { review: args.review } : {}) }), + manifest: parseFocusManifest({ gate: args.gate, ...(args.review ? { review: args.review } : {}), ...(args.manifestExtra ?? {}) }), repo: REPO, issues: args.issues ?? [openIssue(7, "Uploads should retry on 5xx")], pullRequests: args.pullRequests ?? [], + ...(args.changedPaths ? { changedPaths: args.changedPaths } : {}), }); } @@ -216,6 +225,72 @@ describe("buildPredictedGateVerdict", () => { expect(result.note).toContain("scoped to changed paths"); expect(result.note.toLowerCase()).toContain("slop"); }); + + it("with changedPaths supplied, predicts a path-GATED pre-merge check that now matches (#11/#18)", () => { + const result = verdict({ + gate: {}, + changedPaths: ["src/upload/client.ts"], + review: { pre_merge_checks: [{ name: "Tests for src", title_contains: "ZZZ-never", when_paths: ["src/**"], enforce: true }] }, + }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(true); + }); + + it("with changedPaths that do NOT match, the path-gated check is N/A (no finding)", () => { + const result = verdict({ + gate: {}, + changedPaths: ["docs/readme.md"], + review: { pre_merge_checks: [{ name: "Tests for src", title_contains: "ZZZ-never", when_paths: ["src/**"], enforce: true }] }, + }); + expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(false); + }); + + it("predicts a manifest path-policy BLOCK when a changed path hits a blocked glob and manifestPolicy:block (#12)", () => { + const result = verdict({ + gate: { manifestPolicy: "block" }, + manifestExtra: { blockedPaths: ["dist/**"] }, + changedPaths: ["dist/bundle.js"], + }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(true); + // The note no longer disclaims path-policy once paths are supplied, but slop stays disclaimed. + expect(result.note).not.toContain("Provide the PR's changed paths"); + expect(result.note.toLowerCase()).toContain("slop"); + }); + + it("manifestPolicy:advisory does NOT block on a blocked path (parity with the live advisory gate) (#12)", () => { + // The blocked-path finding is critical, so under advisory mode it neither blocks nor surfaces as a warning — + // exactly how the live gate treats it. The meaningful parity is that advisory never fails the prediction. + const result = verdict({ + gate: { manifestPolicy: "advisory" }, + manifestExtra: { blockedPaths: ["dist/**"] }, + changedPaths: ["dist/bundle.js"], + }); + expect(result.conclusion).not.toBe("failure"); + expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(false); + }); + + it("manifestPolicy:off (default) emits NO manifest finding even when a blocked path is touched", () => { + const result = verdict({ + gate: { manifestPolicy: "off" }, + manifestExtra: { blockedPaths: ["dist/**"] }, + changedPaths: ["dist/bundle.js"], + }); + expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(false); + expect(result.warnings.some((w) => w.code === "manifest_blocked_path")).toBe(false); + }); + + it("ignores non-policy guidance findings (e.g. off-focus) — only the three enforceable policy codes are threaded (#12)", () => { + // The path isn't blocked but it's outside the wanted areas → guidance emits the NON-policy `manifest_off_focus`. + // The predictor must skip it (only manifest_blocked_path / _linked_issue_required / _missing_tests are gateable). + const result = verdict({ + gate: { manifestPolicy: "block" }, + manifestExtra: { wantedPaths: ["src/**"] }, + changedPaths: ["docs/readme.md"], + }); + expect(result.conclusion).not.toBe("failure"); + expect([...result.blockers, ...result.warnings].some((f) => f.code === "manifest_off_focus")).toBe(false); + }); }); describe("pack-aware prediction (#693)", () => {