diff --git a/src/api/routes.ts b/src/api/routes.ts index 7ff51ed8d5..c9ed0e7c2f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -159,6 +159,7 @@ import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; +import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { buildBountyAdvisory, buildBurdenForecast, @@ -306,21 +307,21 @@ const PR_VISIBILITY_SKIP_REASONS = [ ] as const satisfies readonly PublicSurfaceSkipReason[]; const preflightSchema = z.object({ - repoFullName: z.string().min(3), - contributorLogin: z.string().min(1).optional(), - title: z.string().min(1), - body: z.string().optional(), - labels: z.array(z.string()).optional(), - changedFiles: z.array(z.string()).optional(), - linkedIssues: z.array(z.number().int().positive()).optional(), - tests: z.array(z.string()).optional(), - authorAssociation: z.string().optional(), + repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), + contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars), + body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), + changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + linkedIssues: z.array(z.number().int().positive()).max(PREFLIGHT_LIMITS.linkedIssues).optional(), + tests: z.array(z.string().max(PREFLIGHT_LIMITS.testChars)).max(PREFLIGHT_LIMITS.tests).optional(), + authorAssociation: z.string().max(PREFLIGHT_LIMITS.authorAssociationChars).optional(), }); const localDiffPreflightSchema = preflightSchema.extend({ changedLineCount: z.number().int().min(0).optional(), - testFiles: z.array(z.string()).optional(), - commitMessage: z.string().optional(), + testFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), }); const skippedPrAuditQuerySchema = z diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d019990199..7c8f1d47c2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -76,6 +76,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoDataQuality } from "../signals/data-quality"; +import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { loadUpstreamStatus } from "../upstream/ruleset"; type AppContext = Context<{ Bindings: Env }>; @@ -111,22 +112,22 @@ const bountyShape = { }; const preflightShape = { - repoFullName: z.string().min(3), - contributorLogin: z.string().min(1).optional(), - title: z.string().min(1), - body: z.string().optional(), - labels: z.array(z.string()).optional(), - changedFiles: z.array(z.string()).optional(), - linkedIssues: z.array(z.number().int().positive()).optional(), - tests: z.array(z.string()).optional(), - authorAssociation: z.string().optional(), + repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), + contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars), + body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), + changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + linkedIssues: z.array(z.number().int().positive()).max(PREFLIGHT_LIMITS.linkedIssues).optional(), + tests: z.array(z.string().max(PREFLIGHT_LIMITS.testChars)).max(PREFLIGHT_LIMITS.tests).optional(), + authorAssociation: z.string().max(PREFLIGHT_LIMITS.authorAssociationChars).optional(), }; const localDiffPreflightShape = { ...preflightShape, changedLineCount: z.number().int().min(0).optional(), - testFiles: z.array(z.string()).optional(), - commitMessage: z.string().optional(), + testFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + commitMessage: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), }; const branchEligibilityShape = { diff --git a/src/signals/engine.ts b/src/signals/engine.ts index c4a63865bb..6ae6662baa 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -23,6 +23,7 @@ import type { PublicContributorProfile } from "../github/public"; import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; import { hasLocalTestEvidence } from "./test-evidence"; +import { PREFLIGHT_LIMITS } from "./preflight-limits"; export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; export type SignalFinding = AdvisoryFinding; @@ -823,7 +824,7 @@ export function buildCollisionReport( /* v8 ignore stop */ const clusterList = [...clusters.values()].sort((left, right) => riskRank(right.risk) - riskRank(left.risk)); - return { + const report = { repoFullName, generatedAt: nowIso(), summary: { @@ -833,6 +834,8 @@ export function buildCollisionReport( }, clusters: clusterList, }; + collisionReportTermCache.set(report, itemTerms); + return report; } export function buildQueueHealth( @@ -2343,7 +2346,9 @@ export function buildPreflightResult( issueQuality?: IssueQualityReport | null | undefined, ): PreflightResult { const lane = buildLaneAdvice(repo, input.repoFullName); - const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(input.body ?? "")])].sort((left, right) => left - right); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars))])].sort( + (left, right) => left - right, + ); // Flag an existing open-work cluster as a possible duplicate when it shares a // linked issue, OR when its title/body meaningfully overlaps the planned // contribution. The previous check used `item.title.includes(input.title)`, @@ -2354,12 +2359,14 @@ export function buildPreflightResult( // symmetric term-overlap heuristic `buildCollisionReport` uses between items // (>=2 shared meaningful terms), which is direction-independent. const plannedTerms = plannedContributionTerms(input); - const collisions = buildCollisionReport(input.repoFullName, issues, pullRequests).clusters.filter((cluster) => + const collisionReport = buildCollisionReport(input.repoFullName, issues, pullRequests); + const itemTerms = collisionReportTermCache.get(collisionReport) ?? new Map(); + const collisions = collisionReport.clusters.filter((cluster) => cluster.items.some((item) => { if (linkedIssues.includes(item.number)) { return true; } - const overlap = termOverlap(plannedTerms, collisionTerms(item)); + const overlap = termOverlap(plannedTerms, itemTerms.get(itemKey(item)) ?? collisionTerms(item)); return overlap.shared >= 2 && overlap.score >= 0.5; }), ); @@ -4067,6 +4074,8 @@ type CollisionTerms = { size: number; }; +const collisionReportTermCache = new WeakMap>(); + function collisionTerms(item: CollisionItem): CollisionTerms { const terms = new Set(tokenize(collisionItemText(item))); return { terms, size: terms.size }; @@ -4079,7 +4088,15 @@ function collisionTerms(item: CollisionItem): CollisionTerms { * uses between items, rather than a one-direction substring test. */ function plannedContributionTerms(input: PreflightInput): CollisionTerms { - const terms = new Set(tokenize([input.title, ...(input.labels ?? []), ...(input.changedFiles ?? [])].join(" "))); + const terms = new Set( + tokenize( + [ + truncateText(input.title, PREFLIGHT_LIMITS.titleChars), + ...boundedTextItems(input.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars), + ...boundedTextItems(input.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars), + ].join(" "), + ), + ); return { terms, size: terms.size }; } @@ -4094,7 +4111,21 @@ function termOverlap(left: CollisionTerms, right: CollisionTerms): { score: numb } function collisionItemText(item: CollisionItem): string { - return [item.title, ...(item.labels ?? []), ...(item.changedFiles ?? [])].filter(Boolean).join(" "); + return [ + truncateText(item.title, PREFLIGHT_LIMITS.titleChars), + ...boundedTextItems(item.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars), + ...boundedTextItems(item.changedFiles, PREFLIGHT_LIMITS.changedFiles, PREFLIGHT_LIMITS.changedFileChars), + ] + .filter(Boolean) + .join(" "); +} + +function boundedTextItems(values: string[] | undefined, maxItems: number, maxChars: number): string[] { + return (values ?? []).slice(0, maxItems).map((value) => truncateText(value, maxChars)); +} + +function truncateText(value: string, maxChars: number): string { + return value.length > maxChars ? value.slice(0, maxChars) : value; } function tokenize(value: string): string[] { diff --git a/src/signals/preflight-limits.ts b/src/signals/preflight-limits.ts new file mode 100644 index 0000000000..dfefd36faf --- /dev/null +++ b/src/signals/preflight-limits.ts @@ -0,0 +1,14 @@ +export const PREFLIGHT_LIMITS = { + repoFullNameChars: 200, + contributorLoginChars: 100, + titleChars: 300, + bodyChars: 20_000, + labelChars: 100, + changedFileChars: 300, + testChars: 300, + authorAssociationChars: 100, + labels: 50, + changedFiles: 200, + linkedIssues: 100, + tests: 50, +} as const; diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index d479d63f19..d6748e26ff 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -34,6 +34,7 @@ import { buildPullRequestReviewability, buildRepoRewardRisk, } from "../../src/signals/reward-risk"; +import { PREFLIGHT_LIMITS } from "../../src/signals/preflight-limits"; import type { GittensorContributorSnapshot } from "../../src/gittensor/api"; import type { ContributorRepoStatRecord, @@ -604,6 +605,35 @@ describe("signal coverage edge cases", () => { expect(preflight.findings.map((finding) => finding.code)).not.toContain("possible_duplicate_work"); }); + it("bounds preflight body scanning for linked issue extraction", () => { + const directRepo = repo("owner/direct"); + const linkedInsideLimit = buildPreflightResult( + { + repoFullName: directRepo.fullName, + title: "Bounded body scan", + body: `Fixes #99 ${"x".repeat(PREFLIGHT_LIMITS.bodyChars + 100)}`, + linkedIssues: [], + }, + directRepo, + [], + [], + ); + const linkedPastLimit = buildPreflightResult( + { + repoFullName: directRepo.fullName, + title: "Bounded body scan", + body: `${"x".repeat(PREFLIGHT_LIMITS.bodyChars)} Fixes #100`, + linkedIssues: [], + }, + directRepo, + [], + [], + ); + + expect(linkedInsideLimit.linkedIssues).toContain(99); + expect(linkedPastLimit.linkedIssues).not.toContain(100); + }); + it("sanitizes public PR comments and supports minimal public signal level", () => { const directRepo = repo("owner/direct"); const prRecord = pr(directRepo.fullName, 55, "Fix cache", { authorLogin: "miner", linkedIssues: [] });