diff --git a/package.json b/package.json index 1efaf04e12..258839b619 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "test:engine-parity": "vitest run test/contract/engine-parity.test.ts", "test:changed": "vitest run --changed=origin/main", "test:workers": "vitest run --config vitest.workers.config.ts", - "test:coverage": "vitest run --coverage", + "test:coverage": "vitest run --coverage --pool=forks", "test:smoke:production": "node scripts/smoke-production.mjs", "test:smoke:observability": "node scripts/smoke-observability-traces.mjs", "test:smoke:browser:install": "playwright install chromium", diff --git a/packages/gittensory-engine/src/advisory/gate-advisory.ts b/packages/gittensory-engine/src/advisory/gate-advisory.ts new file mode 100644 index 0000000000..7fc3c61155 --- /dev/null +++ b/packages/gittensory-engine/src/advisory/gate-advisory.ts @@ -0,0 +1,643 @@ +import { randomUUID } from "node:crypto"; +import type { + Advisory, + AdvisoryConclusion, + AdvisoryFinding, + AdvisorySeverity, + GateRuleMode, + IssueRecord, + PullRequestRecord, + RepositoryRecord, +} from "../types/predicted-gate-types.js"; +import type { CollisionReport } from "../types/predicted-gate-types.js"; +import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner.js"; +import type { GuardrailPathMatch } from "../signals/change-guardrail.js"; +import { nowIso } from "../utils/json.js"; +import { GITTENSORY_GATE_CHECK_NAME } from "../review/check-names.js"; +import { CLA_CHECK_UNRESOLVED_CODE, CLA_CONSENT_MISSING_CODE } from "../review/cla-check.js"; +import { REVIEW_THREAD_BLOCKER_CODE } from "../review/review-thread-findings.js"; +import { labelMatchesPattern } from "../scoring/label-match.js"; + +const CHECK_RUN_FORBIDDEN_TERMS = + /\b(?:rewards?|payouts?|farming|estimated\s+scores?|raw\s+trust\s+scores?|trust\s+scores?|score\s+estimates?|reward\s+estimates?|wallets?|hotkeys?|coldkeys?|reviewability|scoreability|private\s+signals?)\b/gi; + +function sanitizeForCheckRun(text: string): string { + return text.replace(CHECK_RUN_FORBIDDEN_TERMS, "[context]").replace(/\s+/g, " ").trim(); +} + +const DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE = 0.93; +const DEFAULT_SLOP_BLOCK_THRESHOLD = 60; + +export type GateCheckConclusion = "success" | "failure" | "action_required" | "neutral" | "skipped"; + +export type GateCheckPolicy = { + linkedIssueGateMode?: GateRuleMode | undefined; + duplicatePrGateMode?: GateRuleMode | undefined; + /** Historical readiness-score mode. Retained for config compatibility, but readiness is informational only: + * a low readiness score may be surfaced as an advisory warning and must never fail the Gate check. */ + qualityGateMode?: GateRuleMode | undefined; + qualityGateMinScore?: number | null | undefined; + /** When `block`, a dual-model AI consensus defect (`ai_consensus_defect` finding) becomes a hard + * blocker. Defaults to advisory — AI never blocks unless the maintainer opts in. */ + aiReviewGateMode?: GateRuleMode | undefined; + /** Minimum calibrated confidence (0-1) configured for AI close calibration. AI defect findings still block under + * `aiReviewGateMode: block` even when below this floor; the floor remains configurable context, never a guardrail + * that downgrades a blocker to manual review. `null`/undefined ⇒ the 0.93 default. */ + aiReviewCloseConfidence?: number | null | 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; + /** Master "merge-readiness" composite (#551). When set (advisory/block) it OVERRIDES all four sub-gates — + * linked-issue, duplicate, quality/readiness, slop — to its mode, so a maintainer flips ONE switch instead + * of four and the review-agent check stays the single required check. `off` = sub-gates use their own modes. */ + mergeReadinessGateMode?: GateRuleMode | undefined; + /** Focus-manifest policy gate (#555). When `block`, linked-issue/test policy findings become hard blockers. + * Path-based manual-review holds are configured only with `settings.hardGuardrailGlobs`. + * An INDEPENDENT dimension, deliberately NOT folded into the merge-readiness composite so #555 stays focused. + * `off`/`advisory` = the findings stay advisory (never block). Default off. */ + manifestPolicyGateMode?: GateRuleMode | undefined; + /** Self-authored linked-issue gate. When `block`, a `self_authored_linked_issue` finding — raised when + * the PR author also filed the linked issue — becomes a hard blocker. Defaults to `advisory` — the + * finding is surfaced but never blocks unless the maintainer opts in. */ + selfAuthoredLinkedIssueGateMode?: GateRuleMode | undefined; + /** CLA / license-compatibility gate (#2564). When `block`, a `cla_consent_missing` finding — raised when + * neither configured detection method (a consent phrase in the PR body, or a named CLA-bot check-run + * conclusion) confirms consent — becomes a hard blocker. `off` (default) = no finding at all; `advisory` = + * the finding surfaces but never blocks. Independent of every other gate dimension, like manifestPolicy. */ + claGateMode?: GateRuleMode | undefined; + /** First-time-contributor grace (#552). RESERVED / currently INERT (#2266): threaded through from config, + * but evaluateGateCheckCore never reads it (see the removal note below) — a would-be blocker gates a + * genuine newcomer exactly like a repeat contributor. Kept for potential future use. */ + firstTimeContributorGrace?: boolean | undefined; + /** The PR author's merged PR count in THIS repo. RESERVED / currently INERT (#2266) alongside + * firstTimeContributorGrace above — populated but never read by the gate evaluator today. */ + authorMergedPrCount?: number | undefined; + /** The PR author's closed-unmerged PR count in THIS repo. RESERVED / currently INERT (#2266) alongside + * firstTimeContributorGrace above — populated but never read by the gate evaluator today. */ + authorClosedUnmergedPrCount?: number | undefined; + /** The PR author's confirmed-Gittensor status. Carried for context/telemetry only — it no longer + * changes the gate verdict (every author is gated identically; a configured blocker fails the gate + * regardless of confirmed status, which now affects only on-chain scoring). `undefined` = unresolved. + * (#gate-nonconfirmed) */ + confirmedContributor?: boolean | undefined; + /** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR + * >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a + * neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default + * to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */ + sizeGateMode?: GateRuleMode | undefined; + /** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by + * review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a + * matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to + * `off` — the finding is never produced when off, and never blocks under `advisory`. */ + lockfileIntegrityGateMode?: GateRuleMode | undefined; + /** Aggregate change size, threaded from the resolved file list (changedLineCount = additions + deletions). */ + changedFileCount?: number | null | undefined; + changedLineCount?: number | null | undefined; + /** True when the PR's diff trips a configured hard guardrail path. + * A guardrail hit HOLDS an otherwise-passing gate for manual review (neutral → "manual"), never auto-merged. + * Empty/absent guardrail globs disable this path. (#gate-guardrail) */ + guardrailHit?: boolean | undefined; + /** Matched changed paths/globs for the guardrail hold. Empty when the caller only knows "unknown path set" + * (fail-safe guardrail hit) rather than exact paths. */ + guardrailMatches?: GuardrailPathMatch[] | undefined; + /** Dry-run disposition (#gate-dryrun). When true, the gate ALSO computes the would-be conclusion with every + * `advisory` sub-gate promoted to `block` and exposes it as `displayConclusion` (the rendered merge/close/manual + * verdict), WITHOUT changing the posted, non-enforcing `conclusion`. Lets advisory mode show exactly what it WOULD + * do (close/merge/manual) before the maintainer flips to real enforcement. Default off. */ + dryRun?: boolean | undefined; +}; + +export type GateCheckEvaluation = { + enabled: boolean; + conclusion: GateCheckConclusion; + /** Dry-run only (#gate-dryrun): the would-be conclusion (advisory sub-gates promoted to block) used to render the + * merge/close/manual verdict. Absent ⇒ the renderer falls back to `conclusion`. Never affects what is posted. */ + displayConclusion?: GateCheckConclusion | undefined; + title: string; + summary: string; + blockers: AdvisoryFinding[]; + warnings: AdvisoryFinding[]; +}; + +export function buildPullRequestAdvisory( + repo: RepositoryRecord | null, + pr: PullRequestRecord | null, + context: { + otherOpenPullRequests?: PullRequestRecord[]; + requireLinkedIssue?: boolean; + /** Duplicate-winner adjudication (#dup-winner). When true AND this PR is the cluster winner (the lowest + * open sibling number), the `duplicate_pr_risk` finding is suppressed so the winner is not gate-blocked / + * closed as a duplicate. Default/false ⇒ every duplicate sibling keeps the finding (byte-identical). The + * caller sets this to `env.GITTENSORY_DUPLICATE_WINNER === "true"`. */ + duplicateWinnerEnabled?: boolean; + /** Author logins of the linked issues (one entry per resolved issue, may be null when unknown). Used to + * surface a `self_authored_linked_issue` finding when the PR author also opened the linked issue. Absent + * or empty ⇒ the finding is never raised (fail-open: unknown issue authorship stays advisory-only). */ + linkedIssueAuthorLogins?: (string | null | undefined)[]; + /** Same-account issue-avoidance countermeasure (#unlinked-issue-guardrail-followup): `pr.linkedIssues` is + * populated by a pure body-text regex that never checks whether the cited issue is actually OPEN, so a + * contributor can satisfy `linkedIssueGateMode: "block"` by citing an already-CLOSED (or fabricated) + * issue number. When the caller has live-verified that NONE of this PR's linked issue numbers resolve to + * a confirmed-open issue, it sets this true and `missing_linked_issue` fires exactly as if nothing were + * linked at all. Absent/false ⇒ byte-identical to today (presence alone still satisfies the requirement) + * — this is fail-open by construction: the caller only ever sets it true after a live check confirms + * every reference is dead, never on ambiguity. */ + confirmedNoOpenLinkedIssue?: boolean; + } = {}, +): Advisory { + const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; + const targetKey = pr ? `${repoFullName}#${pr.number}` : `${repoFullName}#unknown`; + const findings: AdvisoryFinding[] = []; + if (!repo) { + findings.push({ + code: "repo_not_registered", + severity: "warning", + title: "Repository registration is unknown", + detail: "Gittensory cannot evaluate repo-specific rules until registry data is available.", + action: "Refresh the Gittensor registry snapshot.", + }); + } else { + addRepoFindings(repo, findings); + } + if (!pr) { + findings.push({ + code: "pr_not_cached", + severity: "warning", + title: "Pull request is not cached", + detail: "The GitHub webhook or manual fetch has not recorded this pull request yet.", + action: "Re-deliver the webhook or wait for the next sync.", + }); + } else { + addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? [], Boolean(context.confirmedNoOpenLinkedIssue)); + } + return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); +} + +function addRepoFindings(repo: RepositoryRecord, findings: AdvisoryFinding[]): void { + if (!repo.isRegistered) { + findings.push({ + code: "repo_unregistered", + severity: "warning", + title: "Repository is not registered in the latest snapshot", + detail: "This repository is installed in Gittensory, but the latest registry snapshot does not include it.", + action: "Verify repository registration before relying on Gittensor-specific signals.", + }); + return; + } + if (!repo.registryConfig) { + findings.push({ + code: "repo_config_missing", + severity: "warning", + title: "Repository config was not parsed", + detail: "The repository appears in the registry, but its config was not available in normalized form.", + }); + return; + } + const issueShare = repo.registryConfig.issueDiscoveryShare; + if (issueShare === 0) { + findings.push({ + code: "issue_discovery_disabled", + severity: "info", + title: "Issue discovery is disabled for this repo", + detail: "The current Gittensor registry config routes this repository away from issue-discovery work.", + publicText: "This repo is configured for direct contribution review rather than issue-discovery flow.", + }); + } else if (issueShare === 1) { + findings.push({ + code: "direct_pr_pool_disabled", + severity: "info", + title: "Direct PR scoring is disabled for this repo", + detail: "The current Gittensor registry config routes this repository fully toward issue-discovery work.", + publicText: "This repo is configured around issue-discovery flow. Maintainers should review PR expectations manually.", + }); + } + if (repo.registryConfig.maintainerCut > 0) { + findings.push({ + code: "maintainer_cut_enabled", + severity: "info", + title: "Maintainer allocation is configured", + detail: "This repo has a maintainer allocation configured in the registry.", + }); + } +} + +function addPullRequestFindings( + repo: RepositoryRecord | null, + pr: PullRequestRecord, + findings: AdvisoryFinding[], + otherOpenPullRequests: PullRequestRecord[], + requireLinkedIssue: boolean, + duplicateWinnerEnabled: boolean, + linkedIssueAuthorLogins: (string | null | undefined)[], + confirmedNoOpenLinkedIssue: boolean, +): void { + if (pr.state !== "open") { + findings.push({ + code: "pr_not_open", + severity: "info", + title: "Pull request is not open", + detail: `The pull request state is ${pr.state}.`, + }); + } + const noLinkedIssueCited = pr.linkedIssues.length === 0; + if ((noLinkedIssueCited || confirmedNoOpenLinkedIssue) && requireLinkedIssue) { + findings.push({ + code: "missing_linked_issue", + severity: "warning", + title: "No linked issue detected", + detail: noLinkedIssueCited + ? "No closing reference or linked issue number was found in the PR metadata/body." + : "The PR cites an issue number, but it could not be verified as a currently open issue.", + action: "If this PR is intended to solve an issue, link it explicitly in the PR body.", + }); + } else { + const overlappingPrs = otherOpenPullRequests.filter((otherPr) => + otherPr.linkedIssues.some((issueNumber) => pr.linkedIssues.includes(issueNumber)), + ); + // Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed + // linked-issue claimant, SKIP the duplicate finding — suppressing it suppresses the gate failure, so the + // winner survives while later claimants keep the finding. Sparse legacy rows fail closed instead of + // suppressing duplicate evidence with arbitrary PR-number ordering. + // Flag-OFF (default) short-circuits ⇒ the finding is pushed exactly as before (byte-identical). + if (overlappingPrs.length > 0 && !(duplicateWinnerEnabled && isDuplicateClusterWinnerByClaim(pr, overlappingPrs))) { + findings.push({ + code: "duplicate_pr_risk", + severity: "warning", + title: "Linked issue overlaps another open PR", + detail: `Other open pull requests reference the same linked issue set: ${overlappingPrs.map((otherPr) => `#${otherPr.number}`).join(", ")}.`, + action: "Review the related PRs before spending reviewer time on duplicate work.", + }); + } + } + // Self-authored linked-issue detection: the PR author also filed the linked issue. Raised when at least + // one linked issue's author login is a case-insensitive match for the PR author. Gated by + // selfAuthoredLinkedIssueGateMode — advisory by default so this never blocks without maintainer opt-in. + // Absent/null issue author logins are treated as unknown and never trigger the finding (fail-open). + if (pr.linkedIssues.length > 0 && pr.authorLogin) { + const prAuthor = pr.authorLogin.toLowerCase(); + const selfAuthored = linkedIssueAuthorLogins.some((login) => login != null && login.toLowerCase() === prAuthor); + if (selfAuthored) { + findings.push({ + code: "self_authored_linked_issue", + severity: "warning", + title: "PR author also opened the linked issue", + detail: "The contributor who opened this PR also filed the linked issue. This pattern can indicate artificial issue-discovery work rather than solving an independently discovered problem.", + action: "Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.", + }); + } + } + if (otherOpenPullRequests.length >= 10) { + findings.push({ + code: "busy_pr_queue", + severity: "info", + title: "Review queue is busy", + detail: `Gittensory has ${otherOpenPullRequests.length} other open pull requests cached for this repository.`, + publicText: "This repo has a busy review queue in the local Gittensory cache.", + }); + } + const multiplierPatterns = Object.keys(repo?.registryConfig?.labelMultipliers ?? {}); + const matchedLabels = pr.labels.filter((label) => multiplierPatterns.some((pattern) => labelMatchesPattern(label, pattern))); + if (matchedLabels.length > 0) { + findings.push({ + code: "label_context_found", + severity: "info", + title: "Configured label context found", + detail: `Matched configured labels: ${matchedLabels.join(", ")}.`, + }); + } + if (pr.authorAssociation && ["OWNER", "MEMBER", "COLLABORATOR"].includes(pr.authorAssociation)) { + findings.push({ + code: "maintainer_authored_pr", + severity: "info", + title: "PR author has maintainer association", + detail: "GitHub marks this PR author as owner, member, or collaborator for the repository.", + publicText: "This PR appears to come from a maintainer-associated account.", + }); + } +} + +function advisory( + targetType: Advisory["targetType"], + targetKey: string, + repoFullName: string, + findings: AdvisoryFinding[], + fallbackSummary: string, + pullNumber?: number, + issueNumber?: number, + headSha?: string, +): Advisory { + const severity = highestSeverity(findings); + const conclusion = conclusionForSeverity(severity, findings); + const title = conclusion === "success" ? "Gittensory advisory passed" : "Gittensory advisory available"; + return { + id: randomUUID(), + targetType, + targetKey, + repoFullName, + ...(pullNumber === undefined ? {} : { pullNumber }), + ...(issueNumber === undefined ? {} : { issueNumber }), + ...(headSha === undefined ? {} : { headSha }), + conclusion, + severity, + title, + summary: findings.length > 0 ? `${findings.length} advisory finding${findings.length === 1 ? "" : "s"} generated.` : fallbackSummary, + findings, + generatedAt: nowIso(), + }; +} + +function highestSeverity(findings: AdvisoryFinding[]): AdvisorySeverity { + if (findings.some((finding) => finding.severity === "critical")) return "critical"; + if (findings.some((finding) => finding.severity === "warning")) return "warning"; + return "info"; +} + +function conclusionForSeverity(severity: AdvisorySeverity, findings: AdvisoryFinding[]): AdvisoryConclusion { + if (findings.some((finding) => finding.code === "repo_unregistered" || finding.code === "repo_not_seen")) return "action_required"; + if (severity === "warning") return "neutral"; + if (severity === "critical") return "action_required"; + return "success"; +} + +const SIZE_HOLD_DEFAULT_MAX_FILES = 10; +const SIZE_HOLD_DEFAULT_MAX_LINES = 1000; + +/** Oversized-PR manual-review HOLD finding (#gate-size), or null when the size gate is off or the PR is within both + * thresholds. A HOLD (→ neutral gate → "manual" verdict), never a hard blocker, so it is dry-run/advisory friendly. */ +function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null { + if (!policy.sizeGateMode || policy.sizeGateMode === "off") return null; + let files = policy.changedFileCount; + if (files === undefined || files === null) files = 0; + let lines = policy.changedLineCount; + if (lines === undefined || lines === null) lines = 0; + if ( + files < SIZE_HOLD_DEFAULT_MAX_FILES && + lines < SIZE_HOLD_DEFAULT_MAX_LINES + ) + return null; + return { + code: "oversized_pr", + severity: "warning", + title: "Large change — held for manual review", + detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`, + action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.", + }; +} + +function buildGuardrailHoldFinding(matches: GuardrailPathMatch[] = []): AdvisoryFinding { + const detail = + matches.length > 0 + ? `This PR changes guardrail-protected path(s): ${matches + .slice(0, 5) + .map((match) => `\`${match.path}\` (matched \`${match.glob}\`)`) + .join(", ")}${matches.length > 5 ? `, and ${matches.length - 5} more` : ""}.` + : "This PR changes a guardrail-protected path, or the changed-file list could not be verified while guardrails are configured."; + return { + code: "guardrail_hold", + severity: "warning", + title: "Touches a guarded path — held for manual review", + detail, + action: "A maintainer must review and merge this change.", + }; +} + +function promoteAdvisoryToBlock(policy: GateCheckPolicy): GateCheckPolicy { + // #disposition-redesign: the dry-run "would-be" verdict must reflect the REAL disposition model — a CLOSE is driven by + // the AI reviewer's confidence + genuine hard blockers (secret/CI/banned) ONLY. The advisory signals — missing linked + // issue, readiness/quality, slop, duplicates, manifest policy, self-authored issue — are NEVER close drivers, so they + // are deliberately NOT promoted here. Only the AI sub-gate is promoted, so an `advisory` AI defect still previews its + // would-be close while a missing linked issue or a low readiness score can never render a "close" verdict. + const block = (mode: GateRuleMode | undefined): GateRuleMode | undefined => (mode === "advisory" ? "block" : mode); + return { + ...policy, + dryRun: false, + aiReviewGateMode: block(policy.aiReviewGateMode), + }; +} + +export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation { + const result = evaluateGateCheckCore(advisoryResult, policy); + if (!policy.dryRun) return result; + const wouldBe = evaluateGateCheckCore(advisoryResult, promoteAdvisoryToBlock(policy)); + return { ...result, displayConclusion: wouldBe.conclusion }; +} + +function evaluateGateCheckCore(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation { + const warnings = advisoryResult.findings.filter((finding) => finding.severity === "warning"); + // App/infra state (repo not synced yet, PR not cached): gittensory cannot evaluate this PR yet, so the + // gate is NEUTRAL (non-blocking) and re-evaluates automatically on the next sync/webhook. Never block a + // contributor on the app's OWN state. + if (advisoryResult.findings.some((finding) => isEvaluationBlocker(finding.code, policy))) { + return { + enabled: true, + conclusion: "neutral", + title: `${GITTENSORY_GATE_CHECK_NAME} — not evaluated yet`, + summary: "Gittensory has not finished syncing this repo/PR. The gate stays advisory and re-evaluates automatically; no action is needed.", + blockers: [], + warnings, + }; + } + // Merge-readiness composite (#551): when set, escalate enforceable sub-gates to its mode so they roll into one + // pass/fail. Readiness/quality stays advisory-only. + const effective = applyMergeReadinessGate(policy); + const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding, effective)); + const qualityWarning = buildQualityGateWarning(effective); + const slopBlocker = buildSlopGateBlocker(effective); + const blockers = [...configuredBlockers, ...(slopBlocker ? [slopBlocker] : [])]; + const gateWarnings = qualityWarning ? [...warnings, qualityWarning] : warnings; + // Non-confirmed contributors are gated NORMALLY (real blockers → failure → one-shot close; clean → success → + // merge), the SAME as confirmed contributors: the review + CI + guardrail vet every PR, and confirmed-status + // affects only on-chain SCORING, never the merge/close decision. (#gate-nonconfirmed) The old blanket + // "never block a non-confirmed contributor" forced every non-confirmed PR with a blocker to a neutral → HELD + // state, burying the maintainer in manual review. The old first-time-contributor grace path also softened + // blockers; that is intentionally no longer applied because blocker findings must remain closure/rejection + // outcomes for normal contributors. Owner/automation close exemptions live in the disposition planner instead. + if (blockers.length === 0) { + // Fail-CLOSED AI hold (#ai-fail-closed, #audit-3.5): with NO deterministic blocker, a block-mode AI review + // that could not return a usable verdict HOLDS the gate (neutral) for a human rather than passing + // automatically — NEVER a failure, so a contributor PR is never auto-CLOSED because a model hiccupped. This + // is evaluated AFTER the deterministic blockers above, so a real violation (secret_leak, duplicate, + // missing-issue, slop, quality) still blocks: an inconclusive AI can no longer bury a blocked PR in a hold. + if (advisoryResult.findings.some((finding) => finding.code === "ai_review_inconclusive")) { + return { + enabled: true, + conclusion: "neutral", + title: `${GITTENSORY_GATE_CHECK_NAME} — held for human review`, + summary: "The AI review could not be completed for this change, so the gate is held for a human reviewer rather than passed automatically. It re-evaluates on the next update.", + blockers: [], + warnings: gateWarnings, + }; + } + // Manual-review HOLD (#gate-size / #gate-guardrail): a PR that would otherwise PASS but is oversized or touches + // a guarded path is HELD for a human (neutral → "manual" verdict) rather than auto-approved — never a failure, + // so neutral never blocks the merge (dry-run/advisory friendly) and a contributor PR is never auto-closed for size. + const sizeHold = buildSizeHoldFinding(effective); + const guardrailHold = effective.guardrailHit ? buildGuardrailHoldFinding(effective.guardrailMatches) : null; + const holds = [sizeHold, guardrailHold].filter( + (f): f is AdvisoryFinding => f !== null, + ); + if (holds.length > 0) { + return { + enabled: true, + conclusion: "neutral", + title: `${GITTENSORY_GATE_CHECK_NAME} — held for manual review`, + summary: holds.map((h) => sanitizeForCheckRun(h.title)).join("; "), + blockers: [], + warnings: [...gateWarnings, ...holds], + }; + } + return { + enabled: true, + conclusion: "success", + title: `${GITTENSORY_GATE_CHECK_NAME} passed`, + summary: "No configured hard blocker was found. Advisory findings, if any, stay advisory.", + blockers, + warnings: gateWarnings, + }; + } + // Name the exact blocker(s) + fix in the title so the contributor sees WHY at a glance. + const firstBlocker = blockers[0]; + const titleDetail = blockers.length === 1 && firstBlocker ? sanitizeForCheckRun(firstBlocker.title) : `${blockers.length} blockers`; + return { + enabled: true, + conclusion: "failure", + title: `${GITTENSORY_GATE_CHECK_NAME}: ${titleDetail}`, + summary: blockers + .map((finding) => `${sanitizeForCheckRun(finding.title)}${finding.action ? ` — ${sanitizeForCheckRun(finding.action)}` : ""}`) + .join("; "), + blockers, + warnings: [...advisoryResult.findings.filter((finding) => finding.severity === "warning" && !blockers.includes(finding)), ...(qualityWarning ? [qualityWarning] : [])], + }; +} + +function isEvaluationBlocker(code: string, policy: GateCheckPolicy): boolean { + // pre_merge_check_unresolved: an enforced path-gated pre-merge check whose changed-file set could not be + // resolved — gittensory cannot evaluate it yet, so the gate is NEUTRAL (held) and re-evaluates on the next + // sync, rather than auto-merging past the unverified requirement or hard-closing on a transient miss. (#review-audit) + if (code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved") return true; + // cla_check_unresolved (#2564): the CLA-bot check-run's conclusion could not be resolved. Unlike the codes + // above (which are never mode-gated), evaluateClaCheck runs for BOTH claGateMode "advisory" and "block" (so + // the finding surfaces either way) — only "block" should ever HOLD the gate on an unresolved check-run. + // "advisory" mode's whole contract is "surface findings, never affect the verdict"; unconditionally holding + // here would violate that for any advisory-mode repo using check-run-only detection (#2564 gate-review + // finding). advisory mode still gets the finding in the panel via the normal warnings path below. + if (code === CLA_CHECK_UNRESOLVED_CODE) return policy.claGateMode === "block"; + return false; +} + +function gatePolicyBlocks(mode: GateRuleMode | undefined, defaultMode: GateRuleMode): boolean { + return gateMode(mode ?? defaultMode) === "block"; +} + +function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPolicy): boolean { + const code = finding.code; + // Missing linked issue defaults to ADVISORY — issues aren't always available, so it only blocks when a + // repo explicitly opts in with linkedIssueGateMode: "block". Duplicates still default to blocking. + if (code === "missing_linked_issue") return gatePolicyBlocks(policy.linkedIssueGateMode, "advisory"); + if (code === "duplicate_pr_risk") return gatePolicyBlocks(policy.duplicatePrGateMode, "block"); + // A dual-model AI consensus defect blocks ONLY when the maintainer opted into aiReview: block. It is the + // most conservative AI signal (two independent models) but still confirmed-contributor gated by + // evaluateGateCheck, and advisory by default. + // A consensus defect (both reviewers) OR a SPLIT (one reviewer flagged a blocker the other did not) both block + // when aiReviewGateMode is `block`. The configured close-confidence floor remains calibration context; it does + // not turn a blocker into a manual hold for normal contributors. (#ai-review-split) + if (code === "ai_consensus_defect" || code === "ai_review_split") { + void (policy.aiReviewCloseConfidence ?? DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE); + return gatePolicyBlocks(policy.aiReviewGateMode, "advisory"); + } + if (code === REVIEW_THREAD_BLOCKER_CODE) return true; + // A leaked-secret finding (`secret_leak`) ALWAYS hard-blocks: a committed credential must be removed and + // rotated before merge, with no opt-in. This finding is produced ONLY by the flag-gated safety scan + // (GITTENSORY_REVIEW_SAFETY); when the flag is off the finding never exists, so this branch is unreachable and the + // gate verdict is byte-identical to today. + if (code === "secret_leak") return true; + // A maintainer pre-merge check (#review-pre-merge-checks) marked `enforce: true` produces this DETERMINISTIC + // finding when it fails (a required title/description phrase or label is missing). It always blocks: the + // per-check `enforce` flag in `.gittensory.yml` IS the opt-in (mirroring secret_leak — the finding only exists + // when the maintainer configured an enforced check). The advisory variant (`pre_merge_check_failed`) is a plain + // warning and is never blocked here. No AI judgment is involved, so this can never cause an AI false-close. + if (code === "pre_merge_check_required") return true; + // Focus-manifest policy (#555): linked-issue/test policy findings block ONLY when the maintainer opts into + // manifestPolicy: block. Path holds are intentionally separate and configured via hardGuardrailGlobs. + if (code === "manifest_linked_issue_required" || code === "manifest_missing_tests") { + return gatePolicyBlocks(policy.manifestPolicyGateMode, "off"); + } + // Self-authored linked-issue gate: blocks only when the maintainer opts in with `block`. Defaults to + // advisory — the finding surfaces in the panel without ever closing the PR unless explicitly configured. + if (code === "self_authored_linked_issue") return gatePolicyBlocks(policy.selfAuthoredLinkedIssueGateMode, "advisory"); + // Lockfile-tamper-risk gate (#2563): blocks only when the maintainer opts in with `block`. Defaults to `off` + // (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts), + // so this branch only matters once a repo has explicitly turned the scan on. + if (code === "lockfile_tamper_risk") return gatePolicyBlocks(policy.lockfileIntegrityGateMode, "off"); + // CLA / license-compatibility gate (#2564): blocks only when the maintainer opts into claMode: block. + // Defaults to off (evaluateClaCheck never even runs for an off repo, so the finding does not exist). + if (code === CLA_CONSENT_MISSING_CODE) return gatePolicyBlocks(policy.claGateMode, "off"); + return false; +} + +function buildQualityGateWarning(policy: GateCheckPolicy): AdvisoryFinding | null { + if (gateMode(policy.qualityGateMode) === "off") return null; + const score = normalizeScore(policy.readinessScore); + const minScore = normalizeScore(policy.qualityGateMinScore); + if (score === null || minScore === null || score >= minScore) return null; + return { + code: "readiness_score_below_threshold", + severity: "warning", + title: "Readiness score is below the configured threshold", + detail: `The public readiness score is ${score}/100, below the repository threshold of ${minScore}/100.`, + action: "Use the readiness panel as advisory maintainer context; the score does not block this PR.", + }; +} + +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"; +} + +function applyMergeReadinessGate(policy: GateCheckPolicy): GateCheckPolicy { + const composite = gateMode(policy.mergeReadinessGateMode ?? "off"); + if (composite === "off") return policy; + return { + ...policy, + linkedIssueGateMode: composite, + duplicatePrGateMode: composite, + slopGateMode: composite, + }; +} + +function normalizeScore(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, Math.round(value))); +} + +/** @internal Exported for unit tests of advisory severity wiring. */ +export const gateAdvisoryInternals = { + advisory, + highestSeverity, + conclusionForSeverity, + buildSizeHoldFinding, + buildGuardrailHoldFinding, + promoteAdvisoryToBlock, + isConfiguredGateBlocker, + buildQualityGateWarning, + buildSlopGateBlocker, + gateMode, + gatePolicyBlocks, +}; diff --git a/packages/gittensory-engine/src/focus-manifest/guidance.ts b/packages/gittensory-engine/src/focus-manifest/guidance.ts new file mode 100644 index 0000000000..d3303f0f73 --- /dev/null +++ b/packages/gittensory-engine/src/focus-manifest/guidance.ts @@ -0,0 +1,251 @@ +import type { + FocusManifest, + FocusManifestFinding, + FocusManifestGuidance, +} from "../types/predicted-gate-types.js"; + +const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i; +const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(String.raw`/Users/|/home/|/root/|/var/|/opt/|/tmp/|/private/|[A-Za-z]:[\\/]Users[\\/]|[A-Za-z]:[\\/]Program Files[\\/]`, "i"); + +export function isFocusManifestPublicSafe(text: string): boolean { + return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text); +} + +const MAX_GLOBSTAR_SLASH_ALTERNATIVES = 128; + +function normalizePathForMatch(path: string): string { + return String(path).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase(); +} + +/** + * LINEAR-TIME wildcard matcher for a `*`-glob pattern over an already-normalized path. `*` (and a collapsed + * run of `*`) matches any run of characters INCLUDING `/` (gittensory globs cross slashes). Implemented as a + * prefix + suffix + ordered-substring (indexOf) scan rather than a `.*`-per-star regex: the old regex + * (`^.*a.*a...$`) backtracks catastrophically on a near-miss path and could hang the gate for an entire repo + * (a manifest glob with many non-adjacent `*`). This algorithm is O(path × parts) with NO backtracking. + */ +function linearGlobMatcher(pattern: string): (path: string) => boolean { + // The caller only compiles this for a pattern that contains a wildcard, so split always yields >= 2 parts. + const parts = pattern.split(/\*+/); // literal segments between (collapsed) wildcard runs + const first = parts[0]!; + const last = parts[parts.length - 1]!; + const middles = parts.slice(1, -1).filter((part) => part.length > 0); + return (path) => { + if (!path.startsWith(first) || !path.endsWith(last)) return false; + let idx = first.length; + for (const part of middles) { + const found = path.indexOf(part, idx); + if (found === -1) return false; + idx = found + part.length; + } + return path.length - last.length >= idx; // the suffix must not overlap the consumed prefix/middles + }; +} + +/** + * Compile a manifest path pattern into a predicate over an ALREADY-normalized path. Supports exact paths, + * directory prefixes (`src/` or `src`), and `*` wildcards (`*` and a double-star both match any run of chars + * across `/`). A double-star-then-separator prefix means "zero or more path segments", so the mandatory slash + * is absorbed and a double-star glob also matches a ROOT-level (zero-depth) file, not only nested ones. + * Compiling once lets a caller test many paths against one pattern without recompiling per path — see + * {@link matchedPatterns}. An empty/blank pattern never matches. + */ +function expandGlobstarSlash(pattern: string): string[] { + const alternatives = [""]; + for (let idx = 0; idx < pattern.length; ) { + if (pattern.startsWith("**/", idx)) { + const count = alternatives.length; + const canKeepRootAlternatives = count * 2 <= MAX_GLOBSTAR_SLASH_ALTERNATIVES; + for (let altIdx = count - 1; altIdx >= 0; altIdx -= 1) { + const prefix = alternatives[altIdx]!; + alternatives[altIdx] = `${prefix}*/`; + if (canKeepRootAlternatives) alternatives.push(prefix); + } + idx += 3; + continue; + } + for (let altIdx = 0; altIdx < alternatives.length; altIdx += 1) alternatives[altIdx] += pattern[idx]!; + idx += 1; + } + return alternatives; +} + +function compileManifestPathMatcher(pattern: string): (normalizedPath: string) => boolean { + const normalizedPattern = normalizePathForMatch(pattern); + if (!normalizedPattern) return () => false; + if (normalizedPattern.includes("*")) { + // `**/` means zero or more whole path segments. Keep the slash in the non-root alternative so + // basename globs (e.g. `**/safe.ts`) do not degrade into suffix globs that match `unsafe.ts`. + const matchers = expandGlobstarSlash(normalizedPattern).map((globbed) => + globbed.includes("*") ? linearGlobMatcher(globbed) : (normalizedPath: string) => normalizedPath === globbed, + ); + return (normalizedPath) => matchers.some((matcher) => matcher(normalizedPath)); + } + const dirPattern = normalizedPattern.endsWith("/") ? normalizedPattern : `${normalizedPattern}/`; + return (normalizedPath) => normalizedPath === normalizedPattern || normalizedPath.startsWith(dirPattern); +} + +/** + * Match a changed path against a manifest path pattern. Supports exact paths, directory + * prefixes (`src/` or `src`), and `*` wildcards (`**` collapses to `*`). + */ +export function matchesManifestPath(path: string, pattern: string): boolean { + const normalizedPath = normalizePathForMatch(path); + if (!normalizedPath) return false; + return compileManifestPathMatcher(pattern)(normalizedPath); +} + +function matchedPatterns(paths: string[], patterns: string[]): string[] { + // Normalize each path once and compile each pattern once, instead of redoing both for every (path, + // pattern) pair — the wildcard regex was previously recompiled per path. + const normalizedPaths = paths.map(normalizePathForMatch).filter(Boolean); + return patterns.filter((pattern) => { + const matches = compileManifestPathMatcher(pattern); + return normalizedPaths.some((normalizedPath) => matches(normalizedPath)); + }); +} + +/** + * Build deterministic, public-safe guidance from a focus manifest for a concrete change set. + * Explains why changed paths are preferred or discouraged and surfaces manifest-driven blockers + * without leaking maintainer-private notes into public next steps. + */ +export function buildFocusManifestGuidance(args: { + manifest: FocusManifest; + changedPaths: string[]; + labels?: string[] | undefined; + linkedIssueCount?: number | undefined; + testFileCount?: number | undefined; + passedValidationCount?: number | undefined; +}): FocusManifestGuidance { + const { manifest } = args; + const changedPaths = args.changedPaths.filter((path) => typeof path === "string" && path.length > 0); + const labels = (args.labels ?? []).map((label) => label.toLowerCase()); + const linkedIssueCount = Math.max(0, args.linkedIssueCount ?? 0); + const testFileCount = Math.max(0, args.testFileCount ?? 0); + const passedValidationCount = Math.max(0, args.passedValidationCount ?? 0); + + const matchedWantedPaths = matchedPatterns(changedPaths, manifest.wantedPaths); + const preferredLabelHits = manifest.preferredLabels.filter((label) => labels.includes(label.toLowerCase())); + + const findings: FocusManifestFinding[] = []; + const publicNextSteps: string[] = []; + + if (!manifest.present) { + for (const warning of manifest.warnings) { + findings.push({ code: "manifest_malformed", severity: "info", title: "Maintainer focus manifest not applied", detail: warning }); + } + return { + present: false, + source: manifest.source, + linkedIssuePolicy: manifest.linkedIssuePolicy, + issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, + matchedWantedPaths: [], + preferredLabelHits: [], + findings, + publicNextSteps: [], + warnings: manifest.warnings, + summary: "No maintainer focus manifest applied; using deterministic signals only.", + }; + } + + if (manifest.wantedPaths.length > 0 && matchedWantedPaths.length === 0 && changedPaths.length > 0) { + findings.push({ + code: "manifest_off_focus", + severity: "warning", + title: "Change is outside maintainer-wanted areas", + detail: `No changed path matches the maintainer-wanted patterns (${manifest.wantedPaths.slice(0, 5).join(", ")}).`, + action: "Refocus the change onto a maintainer-wanted area or explain why this out-of-focus work is needed.", + }); + publicNextSteps.push("Refocus onto the maintainer-wanted areas, or explain why this out-of-focus change is needed."); + } + + if (matchedWantedPaths.length > 0) { + findings.push({ + code: "manifest_preferred_path", + severity: "info", + title: "Change aligns with maintainer-wanted areas", + detail: `Changed paths match maintainer-wanted patterns: ${matchedWantedPaths.slice(0, 5).join(", ")}.`, + }); + publicNextSteps.push("Changed paths align with the maintainer's wanted areas for this repo."); + } + + if (manifest.preferredLabels.length > 0 && preferredLabelHits.length === 0) { + findings.push({ + code: "manifest_missing_preferred_label", + severity: "info", + title: "No maintainer-preferred label applied", + detail: `Maintainer prefers labels: ${manifest.preferredLabels.slice(0, 5).join(", ")}.`, + action: "Consider applying a maintainer-preferred label so triage stays aligned.", + }); + publicNextSteps.push(`Consider a maintainer-preferred label (${manifest.preferredLabels.slice(0, 3).join(", ")}).`); + } + + if (manifest.linkedIssuePolicy === "required" && linkedIssueCount === 0) { + findings.push({ + code: "manifest_linked_issue_required", + severity: "warning", + title: "Maintainer requires a linked issue", + detail: "This repo's maintainer focus manifest requires every PR to reference a tracked issue.", + action: "Link the relevant issue (for example `Closes #123`) before opening the PR.", + }); + publicNextSteps.push("Link the relevant tracked issue; the maintainer requires linked issues on PRs."); + } else if (manifest.linkedIssuePolicy === "preferred" && linkedIssueCount === 0) { + findings.push({ + code: "manifest_linked_issue_preferred", + severity: "info", + title: "Maintainer prefers a linked issue", + detail: "This repo's maintainer focus manifest prefers PRs to reference a tracked issue.", + action: "Link a tracked issue if one exists.", + }); + publicNextSteps.push("Link a tracked issue if one exists; the maintainer prefers linked issues."); + } + + if (manifest.testExpectations.length > 0 && testFileCount === 0 && passedValidationCount === 0) { + const safeExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe).slice(0, 3); + const expectationDetail = safeExpectations.length > 0 ? ` Expected evidence: ${safeExpectations.join("; ")}.` : ""; + findings.push({ + code: "manifest_missing_tests", + severity: "warning", + title: "Configured validation evidence missing", + detail: `No changed test files or passing validation evidence were detected for this PR.${expectationDetail}`, + action: "Add regression/invariant coverage, update relevant tests, or attach passing validation output that satisfies the repo's configured expectations.", + }); + publicNextSteps.push("Add relevant tests or passing validation evidence that matches the repo's configured expectations."); + } + + if (manifest.issueDiscoveryPolicy === "discouraged") { + findings.push({ + code: "manifest_issue_discovery_discouraged", + severity: "info", + title: "Maintainer discourages issue-discovery reports", + detail: "This repo's maintainer focus manifest discourages new issue-discovery reports; prefer direct fixes.", + action: "Prefer a direct PR over filing a new issue-discovery report here.", + }); + publicNextSteps.push("This repo prefers direct fixes over new issue-discovery reports."); + } + + const safePublicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe); + const safeNextSteps = [...new Set([...publicNextSteps, ...safePublicNotes])].filter(isFocusManifestPublicSafe); + + return { + present: true, + source: manifest.source, + linkedIssuePolicy: manifest.linkedIssuePolicy, + issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, + matchedWantedPaths, + preferredLabelHits, + findings, + publicNextSteps: safeNextSteps, + warnings: manifest.warnings, + summary: summarize(manifest, matchedWantedPaths), + }; +} + +function summarize(manifest: FocusManifest, wanted: string[]): string { + if (wanted.length > 0) return "Maintainer focus manifest: change aligns with a wanted area."; + if (manifest.wantedPaths.length > 0) return "Maintainer focus manifest: change is outside the wanted areas."; + return "Maintainer focus manifest applied with no path-specific verdict."; +} + +export type { FocusManifest, FocusManifestGuidance, PreMergeCheck } from "../types/predicted-gate-types.js"; diff --git a/packages/gittensory-engine/src/github/constants.ts b/packages/gittensory-engine/src/github/constants.ts new file mode 100644 index 0000000000..a237340c51 --- /dev/null +++ b/packages/gittensory-engine/src/github/constants.ts @@ -0,0 +1 @@ +export const GITTENSOR_HOME_URL = "https://gittensor.io"; diff --git a/packages/gittensory-engine/src/github/sanitize-public-comment.ts b/packages/gittensory-engine/src/github/sanitize-public-comment.ts new file mode 100644 index 0000000000..c650372637 --- /dev/null +++ b/packages/gittensory-engine/src/github/sanitize-public-comment.ts @@ -0,0 +1,27 @@ +export function sanitizePublicComment(value: string): string { + const sanitized = value + .replace(/\bopen pr count\s+\d+\s+exceeds threshold\s+\d+\b\.?/gi, "private context") + .replace(/\bopen pr count is at or below\s+\d+\b/gi, "private context") + .replace(/\bmerged pr count\s+\d+\s+is below upstream floor\s+\d+\b\.?/gi, "private context") + .replace(/\bissue-discovery history\s*\(\s*\d+\s+valid solved,\s*credibility\s+[-+]?\d+(?:\.\d+)?\s*\)\s+is below upstream floors\s*\(\s*\d+\s+valid solved,\s*[-+]?\d+(?:\.\d+)?\s+credibility\s*\)\.?/gi, "private context") + .replace(/\bcredibility\s+[-+]?\d+(?:\.\d+)?\s+is below floor\s+[-+]?\d+(?:\.\d+)?\b\.?/gi, "private context") + .replace(/\b(?:effective|projected|estimated) score(?: changes?)?\b(?:\s+from)?\s+[-+]?\d+(?:\.\d+)?\s*(?:->|→|to)\s*[-+]?\d+(?:\.\d+)?/gi, "private context") + .replace(/\b(raw trust scores?|trust scores?|wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?)\b/gi, "private context") + .replace(/\b(public score estimates?|estimated scores?|score estimates?|estimated rewards?|rewards?|reward estimates?|payouts?|farming|scoreability|score previews?|projected score changes?)\b/gi, "private context") + .replace(/\b(private reviewability|reviewability internals?)\b/gi, "private context") + .replace(/\b(private rankings?|rankings?)\b/gi, "private context") + .replace(/\b(?:open_pr_pressure|closed_pr_credibility|low_credibility|maintainer_lane|inactive_or_unknown_lane|issue_discovery_only|merged_pr_history_floor|issue_discovery_validity_floor)\b/gi, "private context") + .replace(/\b(?:credibility(?: updates?)?|closed pr credibility|low credibility|open pr pressure)\b/gi, "private context") + // Catch-all: a phrase replacement above (e.g. "score estimate"/"score preview") can leave a bare + // numeric score transition behind ("private context 32.5 -> 41.2"); redact those residual numbers too. + .replace(/\bprivate context\b\s+[-+]?\d+(?:\.\d+)?\s*(?:->|→|to)\s*[-+]?\d+(?:\.\d+)?/gi, "private context") + .replace(/\blikely_duplicate\b/gi, "possible overlap with existing work"); + return sanitizeReviewabilityTerm(sanitized).replace(/private context(?:,\s*private context)+/gi, "private context"); +} + +function sanitizeReviewabilityTerm(value: string): string { + return value.replace(/\breviewability\b/gi, (match, offset, fullText: string) => { + const prefix = fullText.slice(Math.max(0, offset - "@gittensory ".length), offset).toLowerCase(); + return prefix.endsWith("@gittensory ") ? match : "private context"; + }); +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index de6ebfdac0..b22240737f 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -231,9 +231,8 @@ export { resolveDuplicateClusterWinnerNumber, type DuplicateClaimMember, } from "./duplicate-winner.js"; -// Predicted-gate type surface + pure helpers (#2276): a miner models its own gate verdict with the same -// shapes the maintainer gate uses; buildPredictedGateVerdict itself follows in #2283. export { + buildPredictedGateVerdict, predictedGateNote, publicSafeFinding, type GateCheckConclusion, diff --git a/packages/gittensory-engine/src/predicted-gate.ts b/packages/gittensory-engine/src/predicted-gate.ts index e0513f445a..d16eb07b8b 100644 --- a/packages/gittensory-engine/src/predicted-gate.ts +++ b/packages/gittensory-engine/src/predicted-gate.ts @@ -1,22 +1,31 @@ -// Predicted-gate type surface + pure helpers, extracted to `@jsonbored/gittensory-engine` (#2276) so a miner can -// model its own "will my PR pass the gate?" verdict locally with the same shapes the maintainer gate uses. This is -// the TYPES-AND-PURE-HELPERS-FIRST slice; `buildPredictedGateVerdict` itself moves in the follow-up keystone -// (#2283) once its signal dependencies are also extracted. `src/rules/predicted-gate.ts` re-exports these so -// there is exactly one definition. -// -// The engine package stays isolated from `src/` (see this package's tsconfig: `rootDir: "src"`, `types: []`), so -// the two small union shapes this surface needs from `src/types.ts` (`GatePolicyPack`) and -// `src/rules/advisory.ts` (`GateCheckConclusion`) are mirrored here rather than imported across the boundary — -// `src/` stays canonical, keep these in sync by hand (mirrors the `scoring/types.ts` convention from #2282). -// Likewise `publicSafeFinding` takes its redaction function as an argument so the engine never reaches back into -// `src/github/commands` for `sanitizePublicComment` (that sanitizer moves to the engine with -// `buildPredictedGateVerdict` in #2283). - -/** Which policy pack a repo's public config selects. Local mirror of `src/types.ts`'s `GatePolicyPack` — keep in sync. */ -export type GatePolicyPack = "gittensor" | "oss-anti-slop"; - -/** Gate check-run conclusion. Local mirror of `src/rules/advisory.ts`'s `GateCheckConclusion` — keep in sync. */ -export type GateCheckConclusion = "success" | "failure" | "action_required" | "neutral" | "skipped"; +import { + buildCollisionReport, + buildPreflightResult, + buildPublicReadinessScore, + buildQueueHealth, + unionScopedOverlapClusters, + type IssueQualityReport, +} from "./signals/predicted-gate-engine.js"; +import { buildFocusManifestGuidance, type FocusManifest } from "./focus-manifest/guidance.js"; +import { guardrailPathMatches, isGuardrailHit } from "./signals/change-guardrail.js"; +import { resolveHardGuardrailGlobs } from "./review/guardrail-config.js"; +import { sanitizePublicComment } from "./github/sanitize-public-comment.js"; +import { GITTENSOR_HOME_URL } from "./github/constants.js"; +import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "./types/predicted-gate-types.js"; + +export type { GatePolicyPack } from "./types/predicted-gate-types.js"; +export type { GateCheckConclusion } from "./advisory/gate-advisory.js"; + +// Opt-in funnel (#694): a non-Gittensor adopter running the `oss-anti-slop` pack learns that Gittensor pays +// contributors for OSS work like this. Public-safe "earn" wording only (never reward/payout/score). +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/gate-advisory.js"; +import { hasValidationNote, isTestPath } from "./signals/test-evidence.js"; +import { evaluateClaCheck } from "./review/cla-check.js"; +import { evaluatePreMergeChecks } from "./review/pre-merge-checks.js"; /** * Pre-submission "will my PR pass the gate?" prediction for a MINER, computed BEFORE a PR exists. @@ -93,12 +102,9 @@ export type PredictedGateInput = { authorAssociation?: string | undefined; }; -/** Redact a gate finding's public-facing text for a predicted verdict. The `sanitize` function is injected - * (rather than imported) so this engine module stays isolated from `src/github/commands`; the backend binds it - * to `sanitizePublicComment`. */ export function publicSafeFinding( finding: { code: string; title: string; detail: string; action?: string | undefined }, - sanitize: (value: string) => string, + sanitize: (value: string) => string = sanitizePublicComment, ) { return { code: finding.code, @@ -107,3 +113,211 @@ export function publicSafeFinding( action: finding.action ? sanitize(finding.action) : undefined, }; } + +/** GitHub full names are case-insensitive — mirror `sameRepo` in the live gate paths. */ +function sameRepoFullName(left: string | null | undefined, right: string | null | undefined): boolean { + return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); +} + +export function buildPredictedGateVerdict(args: { + input: PredictedGateInput; + manifest: FocusManifest; + repo: RepositoryRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + bounties?: BountyRecord[] | undefined; + issueQuality?: IssueQualityReport | null | undefined; + /** The contributor's OWN confirmed-Gittensor status (self-data). Carried through for transparency only — + * 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( + { + repoFullName: input.repoFullName, + contributorLogin: input.contributorLogin, + title: input.title, + body: input.body, + labels: input.labels, + linkedIssues: input.linkedIssues, + authorAssociation: input.authorAssociation, + }, + repo, + issues, + pullRequests, + args.bounties ?? [], + args.issueQuality, + ); + + // A synthetic open PR from the local branch metadata — fed to the SAME advisory builder as a real PR. + // Use preflight's normalized linked issues so body references like "Closes #7" match real PR parity. + const syntheticPr: PullRequestRecord = { + repoFullName: input.repoFullName, + number: 0, + title: input.title, + state: "open", + authorLogin: input.contributorLogin, + authorAssociation: input.authorAssociation ?? null, + body: input.body ?? null, + labels: input.labels ?? [], + linkedIssues: preflight.linkedIssues, + }; + + const collisions = buildCollisionReport(input.repoFullName, issues, pullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); + const readiness = buildPublicReadinessScore({ + pr: syntheticPr, + preflight, + queueHealth, + scopedOverlapCount: unionScopedOverlapClusters(collisions, syntheticPr, preflight.collisions).length, + }); + + // Linked-issue finding is surfaced when the repo's public policy treats it as anything but `off`, so the + // gate can evaluate it; evaluateGateCheck decides whether it actually blocks (block) or stays advisory. + // The composite mergeReadiness gate forces the linked-issue sub-gate on (applyMergeReadinessGate), and the + // live path collects linked-issue evidence whenever merge-readiness is enabled (shouldCollectLinkedIssueEvidence, + // queue/processors.ts), so the predictor must surface the finding under mergeReadiness too — otherwise a + // `mergeReadiness:block` repo with linkedIssue unset predicts a false success while the live gate one-shot + // closes the PR on the missing-linked-issue blocker. (#merge-readiness-parity) + const requireLinkedIssue = + (gate.linkedIssue !== null && gate.linkedIssue !== "off") || (gate.mergeReadiness !== null && gate.mergeReadiness !== "off"); + // `duplicateWinnerEnabled` is INTENTIONALLY omitted (#dup-winner): the prospective PR is synthetic #0, but a + // real new PR opened into an existing duplicate cluster gets the HIGHEST number ⇒ it is always a duplicate + // LOSER, never the winner. So the predictor must keep showing the duplicate finding (the honest pre-submit + // answer). Threading the flag here would let isDuplicateClusterWinner(0, …) treat #0 as the winner and + // falsely suppress the block — a false-optimism regression. Do NOT add it without modeling #0 as the loser. + // Thread linked-issue authors from the issues snapshot so the predictor surfaces the self-authored-linked-issue + // finding too — evaluateGateCheck below already receives gate.selfAuthoredLinkedIssue, but without this finding it + // had nothing to act on, so a configured self-authored gate never showed in the preview. Offline path: resolved + // from the snapshot, never a live fetch. (#self-authored-parity) + const issueAuthorByNumber = new Map(issues.filter((issue) => sameRepoFullName(issue.repoFullName, input.repoFullName)).map((issue) => [issue.number, issue.authorLogin ?? null])); + const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null); + // Mirror the live gate (listOtherOpenPullRequests): repo-scoped open siblings only; closed/merged PRs sharing a + // linked issue must not fire duplicate_pr_risk. authorHistory below still needs every state for its grace counts. + const openSiblings = pullRequests.filter( + (otherPr) => + otherPr.state === "open" && + sameRepoFullName(otherPr.repoFullName, input.repoFullName) && + otherPr.number !== syntheticPr.number, + ); + const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: openSiblings, requireLinkedIssue, linkedIssueAuthorLogins }); + + // 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 }), + ); + + // CLA / license-compatibility gate parity (#2564): this metadata-only predictor never resolves a LIVE + // check-run (it runs before the PR exists), so only the phrase-match detection method is predictable — + // checkRunConclusion stays undefined, mirroring evaluateClaCheck's "not evaluated" contract for an + // unresolved check-run. A repo relying solely on checkRunName (no consentPhrase configured) therefore + // predicts no finding either way; the note below discloses this limitation. + if (gate.claMode !== null && gate.claMode !== "off") { + advisory.findings.push(...evaluateClaCheck({ consentPhrase: gate.claConsentPhrase, checkRunName: gate.claCheckRunName }, { body: syntheticPr.body, checkRunConclusion: undefined })); + } + + // 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, + // Parity with the live gate (queue/processors.ts's manifestPolicyGateMode block): the predictor + // already has the same PR body available via input.body, so a manifest_missing_tests prediction must + // not stay stuck at "no validation evidence" when the real gate would already treat the body as evidence. + passedValidationCount: hasValidationNote(input.body ?? "") ? 1 : 0, + }); + const policyCodes = new Set(["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. + const pack: GatePolicyPack = gate.pack ?? "gittensor"; + const effectiveConfirmedContributor = pack === "oss-anti-slop" ? undefined : args.confirmedContributor; + + // Case-insensitive author match so the PREDICTOR agrees with the live gate (which matches case-insensitively). + // First-time grace is retained as compatibility context, but blocker findings are no longer softened by it. + const contributorLoginLc = input.contributorLogin?.toLowerCase(); + const authorHistory = pullRequests.filter((pr) => sameRepoFullName(pr.repoFullName, input.repoFullName) && pr.authorLogin?.toLowerCase() === contributorLoginLc); + + const hardGuardrailGlobs = resolveHardGuardrailGlobs(manifest.settings); + const evaluation = evaluateGateCheck(advisory, { + linkedIssueGateMode: gate.linkedIssue ?? undefined, + duplicatePrGateMode: gate.duplicates ?? undefined, + qualityGateMode: gate.readinessMode ?? undefined, + qualityGateMinScore: gate.readinessMinScore ?? null, + aiReviewGateMode: gate.aiReviewMode ?? undefined, + aiReviewCloseConfidence: gate.aiReviewCloseConfidence ?? null, + 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, + // #2564: only meaningful when the finding was pushed above (gate.claMode opted in); byte-identical otherwise. + claGateMode: gate.claMode ?? undefined, + readinessScore: readiness.total, + confirmedContributor: effectiveConfirmedContributor, + firstTimeContributorGrace: gate.firstTimeContributorGrace ?? undefined, + authorMergedPrCount: authorHistory.filter((pr) => pr.state === "merged" || pr.mergedAt).length, + authorClosedUnmergedPrCount: authorHistory.filter((pr) => pr.state === "closed" && !pr.mergedAt).length, + // Size-hold + guardrail-hold parity (#2458): only meaningful when changed paths were supplied — changedPaths + // is the only size/guardrail input this metadata-only predictor ever receives, so without it neither can be + // evaluated (byte-identical to before). changedLineCount is deliberately left unset: line-diff stats are + // never sent to this predictor, so the size hold can only be predicted from file count (disclosed in the + // note above) — never claim a line count this function has no way to know. + sizeGateMode: gate.sizeMode ?? undefined, + ...(hasChangedPaths + ? { + changedFileCount: changedPaths.length, + guardrailHit: isGuardrailHit(changedPaths, hardGuardrailGlobs), + guardrailMatches: guardrailPathMatches(changedPaths, hardGuardrailGlobs), + } + : {}), + }); + + return { + predicted: true, + basis: "public_config", + pack, + conclusion: evaluation.conclusion, + title: sanitizePublicComment(evaluation.title), + summary: sanitizePublicComment(evaluation.summary), + readinessScore: readiness.total, + confirmedContributor: effectiveConfirmedContributor, + blockers: evaluation.blockers.map((finding) => publicSafeFinding(finding)), + warnings: evaluation.warnings.map((finding) => publicSafeFinding(finding)), + funnel: pack === "oss-anti-slop" ? { ...OSS_ANTI_SLOP_FUNNEL } : null, + note: predictedGateNote(hasChangedPaths), + }; +} diff --git a/packages/gittensory-engine/src/review/check-names.ts b/packages/gittensory-engine/src/review/check-names.ts new file mode 100644 index 0000000000..c2054e4e14 --- /dev/null +++ b/packages/gittensory-engine/src/review/check-names.ts @@ -0,0 +1 @@ +export const GITTENSORY_GATE_CHECK_NAME = "Gittensory Orb Review Agent"; diff --git a/packages/gittensory-engine/src/review/cla-check.ts b/packages/gittensory-engine/src/review/cla-check.ts new file mode 100644 index 0000000000..e0421a93c8 --- /dev/null +++ b/packages/gittensory-engine/src/review/cla-check.ts @@ -0,0 +1,83 @@ +import type { AdvisoryFinding } from "../types/predicted-gate-types.js"; + +/** Finding code raised when `gate.claMode` is opted in (advisory/block) and neither configured detection method + * (the PR body consent phrase, or the named CLA-bot check-run) confirms consent. ALWAYS severity "warning" at + * generation time — mirrors `manifest_missing_tests`/`manifest_linked_issue_required` (focus-manifest.ts): a + * single finding code whose escalation to a hard blocker is decided entirely by the configured gate MODE + * (isConfiguredGateBlocker, src/rules/advisory.ts), not by this evaluator. */ +export const CLA_CONSENT_MISSING_CODE = "cla_consent_missing"; +/** Finding code emitted when check-run detection is the ONLY configured method and its conclusion could not be + * resolved (a transient fetch failure, not a resolved "no such check-run"). Mirrors `pre_merge_check_unresolved` + * (review/pre-merge-checks.ts): isEvaluationBlocker (advisory.ts) treats this as a NEUTRAL gate (HELD, + * re-evaluates automatically) — never silently skipping a hard requirement and never hard-closing the + * contributor on a transient resolution miss. */ +export const CLA_CHECK_UNRESOLVED_CODE = "cla_check_unresolved"; + +export type ClaCheckConfig = { + /** Public-safe-filtered consent phrase a maintainer requires somewhere in the PR body (case-insensitive + * substring match), e.g. "I have read and agree to the CLA". `null` ⇒ phrase-match detection is not configured. */ + consentPhrase: string | null; + /** Name of a separate CLA-bot check-run this repo also runs (e.g. "CLA Assistant Lite"). When set, a + * `success`/`neutral` conclusion for a check-run with this exact name (case-insensitive) also satisfies + * consent. `null` ⇒ check-run detection is not configured. */ + checkRunName: string | null; +}; + +/** + * Evaluate `.gittensory.yml gate.claMode` + `gate.cla` (consentPhrase / checkRunName) against a PR — + * DETERMINISTICALLY, mirroring the pre-merge-checks title/description phrase-match pattern (review/pre-merge-checks.ts) + * exactly: a case-insensitive substring match against already-resolved PR data, no AI judgment. Consent is + * satisfied when EITHER configured method holds (an "either" contract, not "all", because a repo may only be able + * to detect ONE method for a given PR — e.g. no check-run data was resolved): the PR body contains + * `consentPhrase`, OR a check-run named `checkRunName` concluded `success`/`neutral`. When NEITHER method is + * configured (both null), there is nothing to evaluate — no finding (byte-identical, matches `pre_merge_checks`' + * empty-checks behavior). + * + * `checkRunConclusion` is `undefined` when the caller could not resolve check-run data at all (a transient + * fetch failure, or the predicted-gate metadata-only path, which never sees live check-runs) — that is NOT the + * same as a resolved-but-absent check-run (`null`, "no check-run with this name exists"). When check-run + * detection is configured and its conclusion is unresolved (a transient fetch failure, or "not yet run"), this + * HOLDS (`cla_check_unresolved`) instead of failing closed — exactly like an unresolved changed-file set HOLDS + * a path-gated pre-merge check rather than silently skipping (auto-merge bypass) or hard-closing on a + * transient miss. This applies EVEN WHEN `consentPhrase` is ALSO configured but not (yet) satisfied: per the + * "either method holds ⇒ satisfied" contract above, an unresolved check-run might still satisfy consent, so + * deciding purely from a not-yet-satisfied phrase would hard-fail a PR the check-run could have saved (#2564 + * gate-review finding). A hold only degrades to a hard `cla_consent_missing` once EVERY configured method has + * been definitively resolved and none of them is satisfied. Pure + side-effect-free; the caller pushes the + * finding into the advisory before the gate evaluates. + */ +export function evaluateClaCheck( + config: ClaCheckConfig, + ctx: { body?: string | null | undefined; checkRunConclusion?: string | null | undefined }, +): AdvisoryFinding[] { + if (config.consentPhrase === null && config.checkRunName === null) return []; // nothing configured ⇒ no finding + const phraseSatisfied = config.consentPhrase !== null && (ctx.body ?? "").toLowerCase().includes(config.consentPhrase.toLowerCase()); + const checkRunSatisfied = config.checkRunName !== null && (ctx.checkRunConclusion === "success" || ctx.checkRunConclusion === "neutral"); + if (phraseSatisfied || checkRunSatisfied) return []; + // A configured check-run whose conclusion is unresolved: cannot confirm OR deny consent via that method, so + // HOLD rather than fail closed — regardless of whether consentPhrase is ALSO configured (a not-yet-satisfied + // phrase does not mean consent is definitively absent while the check-run could still satisfy it). + if (config.checkRunName !== null && ctx.checkRunConclusion === undefined) { + return [ + { + code: CLA_CHECK_UNRESOLVED_CODE, + severity: "warning", + title: `CLA check held — "${config.checkRunName}" not resolved`, + detail: `Gittensory could not resolve the "${config.checkRunName}" check-run's conclusion for this PR; the gate is held and re-evaluates automatically.`, + action: "No action needed — the gate re-evaluates once the check-run's conclusion is available.", + }, + ]; + } + const missing: string[] = []; + if (config.consentPhrase !== null) missing.push(`the PR description must contain "${config.consentPhrase}"`); + if (config.checkRunName !== null) missing.push(`the "${config.checkRunName}" check must pass`); + return [ + { + code: CLA_CONSENT_MISSING_CODE, + severity: "warning", + title: "CLA consent not confirmed", + detail: `This PR does not confirm contributor license agreement consent: ${missing.join(" or ")}.`, + action: "Add the required CLA consent phrase to the PR description, or complete the CLA check, then re-run the gate.", + }, + ]; +} diff --git a/packages/gittensory-engine/src/review/diff-file-priority.ts b/packages/gittensory-engine/src/review/diff-file-priority.ts new file mode 100644 index 0000000000..a4584cbebe --- /dev/null +++ b/packages/gittensory-engine/src/review/diff-file-priority.ts @@ -0,0 +1,9 @@ +import { isTestPath } from "../signals/test-evidence.js"; + +export function diffFilePriority(path: string): number { + if (/(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock|bun\.lockb|cargo\.lock|poetry\.lock|pipfile\.lock|composer\.lock|gemfile\.lock|go\.sum|go\.work\.sum|uv\.lock|packages\.lock\.json|flake\.lock|deno\.lock|pubspec\.lock|podfile\.lock|mix\.lock|package\.resolved|gradle\.lockfile|pdm\.lock|conan\.lock|pixi\.lock|cartfile\.lock|gopkg\.lock|shard\.lock|rebar\.lock|renv\.lock|chart\.lock)$|\.(min\.(js|css)|map|snap)$/i.test(path)) return 4; + if (/(^|\/)(dist|build|out|coverage|vendor|node_modules)\//i.test(path)) return 4; + if (/\.(md|mdx|markdown|rst|adoc|asciidoc|txt)$/i.test(path)) return 2; + if (isTestPath(path)) return 1; + return 0; +} diff --git a/packages/gittensory-engine/src/review/guardrail-config.ts b/packages/gittensory-engine/src/review/guardrail-config.ts new file mode 100644 index 0000000000..e08f36401f --- /dev/null +++ b/packages/gittensory-engine/src/review/guardrail-config.ts @@ -0,0 +1,12 @@ +import type { RepositorySettings } from "../types/predicted-gate-types.js"; + +/** + * Resolve hard-guardrail path globs from the already-effective repo settings. Path holds are config-as-code only: + * omitted/null settings mean no path guardrails, and arrays replace lower layers wholesale. + */ +export function resolveHardGuardrailGlobs( + settings: Pick | null | undefined, +): string[] { + const configured = settings?.hardGuardrailGlobs; + return Array.isArray(configured) ? [...configured] : []; +} diff --git a/packages/gittensory-engine/src/review/pre-merge-checks.ts b/packages/gittensory-engine/src/review/pre-merge-checks.ts new file mode 100644 index 0000000000..1be7d320cf --- /dev/null +++ b/packages/gittensory-engine/src/review/pre-merge-checks.ts @@ -0,0 +1,67 @@ +import { matchesManifestPath, type PreMergeCheck } from "../focus-manifest/guidance.js"; +import type { AdvisoryFinding } from "../types/predicted-gate-types.js"; + +/** Finding code for a FAILED advisory (default) pre-merge check — surfaced but NEVER blocks. */ +export const PRE_MERGE_CHECK_ADVISORY_CODE = "pre_merge_check_failed"; +/** Finding code for a FAILED pre-merge check the maintainer marked `enforce: true` — a hard gate blocker + * (isConfiguredGateBlocker treats this code as blocking, like secret_leak). */ +export const PRE_MERGE_CHECK_BLOCKING_CODE = "pre_merge_check_required"; +/** Finding code emitted when an ENFORCED `whenPaths`-gated check cannot be evaluated because the PR's changed-file + * set could not be resolved. isEvaluationBlocker (advisory.ts) treats this as a NEUTRAL gate (HELD, re-evaluates + * automatically) — never silently skipping a hard requirement (auto-merge bypass) and never hard-closing the + * contributor on a transient resolution miss. (#review-audit) */ +export const PRE_MERGE_CHECK_UNRESOLVED_CODE = "pre_merge_check_unresolved"; + +/** + * Evaluate the maintainer's `.gittensory.yml review.pre_merge_checks` against a PR — DETERMINISTICALLY, with no AI + * judgment. A check with `whenPaths` applies only when a changed path matches; it PASSES only when EVERY configured + * assertion holds (the title contains `titleContains`, the body contains `descriptionContains`, and the + * `requireLabel` label is present — all case-insensitive). Each FAILED check yields ONE finding: + * `pre_merge_check_required` (severity critical → the gate blocks under enforce) or `pre_merge_check_failed` + * (severity warning → advisory). Pure + side-effect-free; the caller pushes the findings into the advisory before + * the gate evaluates. Empty `checks` ⇒ no findings (byte-identical). + */ +export function evaluatePreMergeChecks( + checks: PreMergeCheck[], + ctx: { title?: string | null | undefined; body?: string | null | undefined; labels?: string[] | null | undefined; changedPaths: string[]; filesResolved?: boolean | undefined }, +): AdvisoryFinding[] { + const title = (ctx.title ?? "").toLowerCase(); + const body = (ctx.body ?? "").toLowerCase(); + const labels = (ctx.labels ?? []).map((label) => label.toLowerCase()); + const filesResolved = ctx.filesResolved ?? true; // absent ⇒ caller asserts a trustworthy changedPaths set + const findings: AdvisoryFinding[] = []; + for (const check of checks) { + // when_paths gate: a check with whenPaths applies ONLY to PRs that touch a matching path; an unmatched check + // is N/A (no finding). Empty whenPaths ⇒ the check always applies (title/description/label only). + if (check.whenPaths.length > 0) { + if (!filesResolved) { + // The changed-file set could not be resolved, so we cannot evaluate this path gate. HOLD the gate for an + // ENFORCED check (re-evaluates when files resolve) instead of silently skipping a hard requirement (which + // would let a guarded PR auto-merge). An advisory check is just dropped (no noise on a transient miss). + if (check.enforce) + findings.push({ + code: PRE_MERGE_CHECK_UNRESOLVED_CODE, + severity: "warning", + title: `Pre-merge check held — changed files not resolved: ${check.name}`, + detail: `Gittensory could not resolve this PR's changed files to evaluate the path-gated check "${check.name}"; the gate is held and re-evaluates automatically.`, + action: "No action needed — the gate re-evaluates once the PR's files are available.", + }); + continue; + } + if (!ctx.changedPaths.some((path) => check.whenPaths.some((glob) => matchesManifestPath(path, glob)))) continue; + } + const unmet: string[] = []; + if (check.titleContains !== null && !title.includes(check.titleContains.toLowerCase())) unmet.push(`the title must contain "${check.titleContains}"`); + if (check.descriptionContains !== null && !body.includes(check.descriptionContains.toLowerCase())) unmet.push(`the description must contain "${check.descriptionContains}"`); + if (check.requireLabel !== null && !labels.includes(check.requireLabel.toLowerCase())) unmet.push(`the "${check.requireLabel}" label must be applied`); + if (unmet.length === 0) continue; // every configured assertion held → the check passed + findings.push({ + code: check.enforce ? PRE_MERGE_CHECK_BLOCKING_CODE : PRE_MERGE_CHECK_ADVISORY_CODE, + severity: check.enforce ? "critical" : "warning", + title: `Pre-merge check not satisfied: ${check.name}`, + detail: `This PR does not satisfy the maintainer pre-merge check "${check.name}": ${unmet.join("; ")}.`, + action: "Update the PR to satisfy the check, then re-run the gate.", + }); + } + return findings; +} diff --git a/packages/gittensory-engine/src/review/review-thread-findings.ts b/packages/gittensory-engine/src/review/review-thread-findings.ts new file mode 100644 index 0000000000..eba25dae02 --- /dev/null +++ b/packages/gittensory-engine/src/review/review-thread-findings.ts @@ -0,0 +1 @@ +export const REVIEW_THREAD_BLOCKER_CODE = "review_thread_unresolved"; diff --git a/packages/gittensory-engine/src/scoring/label-match.ts b/packages/gittensory-engine/src/scoring/label-match.ts new file mode 100644 index 0000000000..253a0ecfc4 --- /dev/null +++ b/packages/gittensory-engine/src/scoring/label-match.ts @@ -0,0 +1,144 @@ +import { hasUnsafeWildcardCount } from "../signals/change-guardrail.js"; + +export function labelMatchesPattern(label: string, pattern: string): boolean { + return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase()); +} + +// Compiled fnmatch→RegExp matchers are memoized by pattern. The same small, +// config-derived set of label keys is matched on every scored PR/issue, so the +// per-call recompile inside the nested label loops in engine.ts is pure waste. +// Keys come from a repo's registryConfig.labelMultipliers, sourced from the externally-fetched gittensor +// registry (registry/sync.ts + registry/normalize.ts, not a value this repo's own maintainer directly controls +// via .gittensory.yml) — so the pattern SET is small per repo, but individual pattern CONTENT is untrusted, not +// literally attacker-supplied-per-request the way GitHub PR content is. The wildcard-count cap below (#2456) +// bounds a single pattern's compile cost; this cache is additionally bounded to a fixed max entry count and +// evicted LRU, so a long-running isolate that observes many distinct registry snapshots over its life still +// can't grow the cache unboundedly. The compiled RegExp carries only the "i" flag (no global/sticky `lastIndex` +// state), so sharing one instance across calls is safe and byte-identical to recompiling on every call. +export const LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES = 256; +const labelPatternRegExpCache = new Map(); + +// A RegExp that never matches any input — mirrors change-guardrail.ts's identical NEVER_MATCHES fallback for an +// over-complex pattern, so a pathological registry entry degrades to "this label multiplier never applies" +// instead of hanging the scoring path that evaluates it. +const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/; + +// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a +// literal string: `fnmatch(label.lower(), pattern.lower())` in +// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or +// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The +// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the +// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch +// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in +// change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any +// run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a +// pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically. +function labelPatternToRegExp(pattern: string): RegExp { + const cached = labelPatternRegExpCache.get(pattern); + if (cached !== undefined) { + // Refresh recency on hit so the cache behaves as an LRU: the most-recently-matched patterns + // survive eviction, not just the most-recently-inserted ones. + labelPatternRegExpCache.delete(pattern); + labelPatternRegExpCache.set(pattern, cached); + return cached; + } + // Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars" + // semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically- + // safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match + // instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public + // score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise + // hang scoring for every PR on that repo. + if (hasUnsafeWildcardCount(pattern)) { + setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES); + return LABEL_PATTERN_NEVER_MATCHES; + } + let regex = ""; + let i = 0; + while (i < pattern.length) { + const char = pattern.charAt(i); + i += 1; + if (char === "*") { + regex += ".*"; + } else if (char === "?") { + regex += "."; + } else if (char === "[") { + const close = pattern.indexOf("]", i); + if (close === -1) { + // No closing bracket: fnmatch treats the `[` as a literal character. + regex += "\\["; + } else { + const rawBody = pattern.slice(i, close); + if (rawBody === "" || rawBody === "!") { + // Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes. + regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`; + } else if (hasDescendingCharacterRange(rawBody)) { + // Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws. + regex += "(?!)"; + } else { + let body = rawBody.replace(/\\/g, "\\\\"); + // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`. + if (body.startsWith("!")) body = `^${body.slice(1)}`; + else if (body.startsWith("^")) body = `\\${body}`; + regex += `[${body}]`; + } + i = close + 1; + } + } else if (/[.+^${}()|\]\\]/.test(char)) { + regex += `\\${char}`; + } else { + regex += char; + } + } + const compiled = new RegExp(`^${regex}$`, "i"); + setLabelPatternRegExpCacheEntry(pattern, compiled); + return compiled; +} + +// Inserts a new (never-before-cached) entry, evicting the least-recently-used entry first if the +// cache is already at its bound. Callers must only use this for keys not already present — refreshing +// an existing key's recency on a cache hit is handled inline above via delete+set. +function setLabelPatternRegExpCacheEntry(pattern: string, compiled: RegExp): void { + if (labelPatternRegExpCache.size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES) { + // Map iteration order is insertion order, so the first key is always the least-recently-used + // one (recency is refreshed via delete+set on every hit/insert). The map is non-empty here + // because size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES (a positive constant), so the loop body + // always runs exactly once. + for (const oldestPattern of labelPatternRegExpCache.keys()) { + labelPatternRegExpCache.delete(oldestPattern); + break; + } + } + labelPatternRegExpCache.set(pattern, compiled); +} + +export function clearLabelPatternRegExpCacheForTest(): void { + labelPatternRegExpCache.clear(); +} + +export function labelPatternRegExpCacheKeysForTest(): string[] { + return [...labelPatternRegExpCache.keys()]; +} + +function escapeRegExpLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasDescendingCharacterRange(body: string): boolean { + const start = body.startsWith("!") ? 1 : 0; + // Walk the class left-to-right, consuming each `X-Y` range as a unit so a range endpoint can't be + // misread as the start of a spurious second range. Only a genuinely inverted range like `[z-a]` — the + // case JS `RegExp` actually throws on — must degrade the class to never-match; a literal `-` that + // follows a completed range (as in `[a-z-9]`, a valid class) must NOT be suppressed. The prior scan + // flagged any `-` whose left neighbor outranked its right neighbor, so it wrongly killed `[a-z-9]`. + let i = start; + while (i < body.length) { + if (i + 2 < body.length && body.charAt(i + 1) === "-") { + if (body.charCodeAt(i) > body.charCodeAt(i + 2)) return true; + i += 3; + } else { + i += 1; + } + } + return false; +} + diff --git a/packages/gittensory-engine/src/signals/change-guardrail.ts b/packages/gittensory-engine/src/signals/change-guardrail.ts new file mode 100644 index 0000000000..91142a2544 --- /dev/null +++ b/packages/gittensory-engine/src/signals/change-guardrail.ts @@ -0,0 +1,168 @@ +// Convergence safety: the hard-guardrail path check for the auto-maintain layer (#778). Changed paths that +// match a repo's configured hardGuardrailGlobs force MANUAL review — gittensory must never auto-merge OR +// auto-close a PR that touches a guarded path. Ported verbatim from +// reviewbot core/change-classifier.ts — the mechanism that prevents the awesome-claude #4196 incident class +// (a weakened policy script auto-merging because its path wasn't guarded). Pure + dependency-free. + +// Canonicalize a path or glob so matching is case- and separator-insensitive: backslashes → `/`, drop a +// leading `./` or `/`, and case-fold. Mirrors signals/focus-manifest `normalizePathForMatch` — without it a +// guarded path is evaded with `.github/Workflows/` (capital W), a `./`-prefix, or a `\` separator, turning a +// mandatory human hold on CI/policy files into an auto-merge. +export function canonicalize(value: string): string { + return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase(); +} + +// globToRegExp's COMPILATION is linear-time, but the COMPILED pattern's .test() can be polynomial-to-exponential +// time on an adversarial near-miss input when MULTIPLE wildcard GROUPS chain in one glob (a "group" is one `*` +// OR one `**` — a `**` pair compiles to a SINGLE `.*`, not two independent wildcards, so it must be counted as +// ONE group, not two characters; see countWildcardGroups below). Both group TYPES contribute to the same danger +// once chained — `[^/]*` groups separated by a literal that class doesn't exclude (e.g. "-", not "/") back- +// track ambiguously, and `.*` groups back-track ambiguously EVEN when "/"-separated, since `.*` crosses `/` +// freely. Re-benchmarked against `path` lengths GitHub can plausibly deliver via a deeply nested file path in a +// malicious PR (both `path` and, via `.gittensory.yml`'s contentLane.*Glob fields, the glob itself can be +// attacker-influenced in the same PR): +// 2 wildcard groups (any mix of `*`/`**`, any arrangement): sub-second even at a wildly implausible 32,000- +// char adversarial path (worst case observed: ~400ms) — quadratic, bounded, never a +// realistic hang. +// 3 wildcard groups: OVER 2 SECONDS at just ~4,000 chars for one chained-`*` shape, over 100ms at ~1,600 +// chars for a chained-`**` shape — already dangerous well within a plausible path length. +// 4+ wildcard groups: confirmed catastrophic — 35 SECONDS at just 1,614 chars for 4 chained `**` groups. +// hardGuardrailGlobs are maintainer-configured, and this compiler is also exported for reuse by other +// maintainer-config-driven consumers (content-lane/spec-resolver.ts, whose real globs like "public/**/*.json" are +// exactly 2 groups: this cap must stay inclusive of that legitimate shape, not just "safer than before"), so the +// cap lives INSIDE globToRegExp itself (not just in a wrapper like matchesAny below) — every caller is +// protected automatically rather than needing to separately remember the risk. The boundary is set at the +// highest GROUP count proven safe by the benchmark above (2) — a boundary that itself sits inside the +// empirically dangerous range would defeat the point of a cap. +const MAX_GLOB_WILDCARD_GROUPS = 2; + +/** Count `*` GROUPS in `glob` — a `**` pair is ONE group (it compiles to a single `.*`, see globToRegExp), not + * two. Mirrors globToRegExp's own tokenization exactly (including consuming a `**`'s trailing `/`) so the count + * reflects the actual number of backtracking-capable groups the compiled RegExp will contain, not raw `*` + * character count (which would double-count every globstar and reject legitimate globs like + * "public/**\/*.json" — 2 real groups — as if they were 3-groups-dangerous). */ +function countWildcardGroups(glob: string): number { + let count = 0; + for (let i = 0; i < glob.length; i += 1) { + if (glob.charAt(i) !== "*") continue; + count += 1; + if (glob.charAt(i + 1) === "*") { + i += 1; // consume the second star of the "**" pair — one group, not two + if (glob.charAt(i + 1) === "/") i += 1; // `**/` also matches zero segments, mirroring globToRegExp + } + } + return count; +} + +/** True if `glob` has more wildcard GROUPS than can be safely compiled to a RegExp without risking catastrophic + * backtracking (see the MAX_GLOB_WILDCARD_GROUPS rationale above). Exported so any OTHER glob-accepting config + * surface (e.g. focus-manifest.ts's contentLane.*Glob parsing) can reject an over-complex glob using the SAME + * predicate globToRegExp itself enforces — a caller with its own, independently-counted threshold could accept + * a glob globToRegExp then silently compiles to NEVER_MATCHES, configuring a lane that can never activate. */ +export function hasUnsafeWildcardCount(glob: string): boolean { + return countWildcardGroups(glob) > MAX_GLOB_WILDCARD_GROUPS; +} + +// A RegExp that never matches any input, at any position — the safe, conservative compiled form of an +// over-complex glob. "Never matches" (not "matches everything") is the correct default HERE because +// globToRegExp has no context on caller intent, and a false "matches everything" would be actively wrong for a +// non-guardrail caller (e.g. content-lane file-scope matching, where "matches everything" would misclassify +// every changed file as a registry submission). A caller whose OWN semantics want the opposite fail direction +// (a security guardrail, where under-protection is worse than an unnecessary hold) checks hasUnsafeWildcardCount +// itself and overrides — see matchesAny below. +const NEVER_MATCHES = /^(?!)$/; + +/** Convert a path glob (`*` matches within a segment, `**` matches across `/`) to an anchored RegExp. The + * glob is canonicalized first, so matching is case-insensitive against a canonicalized path. Exported for + * reuse anywhere a maintainer-supplied path pattern needs compiling — never compile a raw regex string from + * config (ReDoS risk); this linear-time glob compiler is the one safe path pattern this codebase uses. + * + * An over-complex glob (see MAX_GLOB_WILDCARD_GROUPS) short-circuits to NEVER_MATCHES instead of being compiled — + * this function never returns a RegExp that risks catastrophic backtracking on .test(), for any input. */ +export function globToRegExp(glob: string): RegExp { + if (hasUnsafeWildcardCount(glob)) return NEVER_MATCHES; + const canonical = canonicalize(glob); + let re = ""; + for (let i = 0; i < canonical.length; i += 1) { + const c = canonical.charAt(i); + if (c === "*") { + if (canonical.charAt(i + 1) === "*") { + re += ".*"; + i += 1; + if (canonical.charAt(i + 1) === "/") i += 1; // `**/` also matches zero segments + } else { + re += "[^/]*"; + } + } else if (/[.+?^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +/** + * True if `path` matches any of the globs (`*` within a segment, `**` across `/`), case-insensitively. A glob + * with more wildcards than can be safely compiled (see hasUnsafeWildcardCount) is treated as matching EVERY + * path — fail SAFE TOWARD GUARDING, mirroring isGuardrailHit's own "unknown ⇒ treat as a hit" philosophy (an + * over-complex guardrail glob still forces manual review) rather than the NEVER_MATCHES default globToRegExp + * itself falls back to, which would silently disable the maintainer's intended protection — the worse failure + * mode for a safety guardrail specifically (see globToRegExp's own docstring for why NEVER_MATCHES is still the + * right default for globToRegExp as a general-purpose compiler). + */ +export function matchesAny(path: string, globs: string[]): boolean { + const canonicalPath = canonicalize(path); + return globs.some((g) => hasUnsafeWildcardCount(g) || globToRegExp(g).test(canonicalPath)); +} + +/** + * The changed paths (if any) that trip a hard guardrail. A non-empty result means the PR touches a guarded + * path and MUST fall through to a human — gittensory may neither auto-merge nor auto-close it. Pure. + */ +export function changedPathsHittingGuardrail(changedPaths: string[], hardGuardrailGlobs: string[]): string[] { + if (hardGuardrailGlobs.length === 0) return []; + return changedPaths.filter((path) => path.length > 0 && matchesAny(path, hardGuardrailGlobs)); +} + +export type GuardrailPathMatch = { + path: string; + glob: string; +}; + +/** + * Structured guardrail match details for public review output + audit logs. Over-complex globs preserve the + * same fail-safe direction as {@link matchesAny}: they match every non-empty path rather than silently disabling + * a maintainer's guardrail. Unknown changed paths are represented by {@link isGuardrailHit}'s boolean path only, + * so callers can say "paths unavailable" without inventing a fake path. + */ +export function guardrailPathMatches(changedPaths: string[], hardGuardrailGlobs: string[]): GuardrailPathMatch[] { + if (hardGuardrailGlobs.length === 0 || changedPaths.length === 0) return []; + const matches: GuardrailPathMatch[] = []; + for (const path of changedPaths) { + if (path.length === 0) continue; + const canonicalPath = canonicalize(path); + for (const glob of hardGuardrailGlobs) { + if (hasUnsafeWildcardCount(glob)) { + matches.push({ path, glob }); + continue; + } + if (globToRegExp(glob).test(canonicalPath)) { + matches.push({ path, glob }); + } + } + } + return matches; +} + +/** + * Whether a PR's diff trips a hard guardrail — the BOOLEAN form shared by the disposition (held for owner + * review) and the public comment (so the headline reads "held", not "safe to merge"). FAIL-SAFE on unknown + * paths (#1062): when guardrails ARE configured but the changed-file set is empty (the cache is not yet / no + * longer populated), we cannot prove the PR avoids a guarded path, so treat it as a hit. No guardrails + * configured ⇒ never a hit. Pure. + */ +export function isGuardrailHit(changedPaths: string[], hardGuardrailGlobs: string[]): boolean { + if (hardGuardrailGlobs.length === 0) return false; + return changedPaths.length === 0 || changedPathsHittingGuardrail(changedPaths, hardGuardrailGlobs).length > 0; +} diff --git a/packages/gittensory-engine/src/signals/duplicate-winner.ts b/packages/gittensory-engine/src/signals/duplicate-winner.ts new file mode 100644 index 0000000000..9de8f9f496 --- /dev/null +++ b/packages/gittensory-engine/src/signals/duplicate-winner.ts @@ -0,0 +1,105 @@ +/** + * Duplicate-winner adjudication (#dup-winner). Flag-gated by GITTENSORY_DUPLICATE_WINNER. + * + * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks + + * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is + * spared: the earliest claimant. Sparse legacy rows that do not yet have claim timing fail closed so unknown + * ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are blocked/closed; the winner + * still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. + * + * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the + * caller can compute the winner ONCE per review run and thread the result boolean consistently into every + * surface (advisory finding, close reason, slop, panels), so they agree by construction. + * + * ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` — + * the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time, + * i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation + * order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill + * reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a + * LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared + * only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy + * claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly. + * + * INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The + * existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open + * set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster. + */ + +export type DuplicateClaimMember = { + number: number; + linkedIssueClaimedAt?: string | null | undefined; + /** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */ + createdAt?: string | null | undefined; +}; + +/** + * True iff `prNumber` is the cluster winner: the minimum of `{prNumber} ∪ openSiblingNumbers`. An empty + * sibling list ⇒ the PR is alone in (or out of) the cluster ⇒ winner. A sibling list that happens to contain + * `prNumber` itself is harmless — the comparison is still min-based. + * + * @deprecated Use {@link isDuplicateClusterWinnerByClaim}. PR-number election is retained only for legacy + * compatibility callers that do not have claim timestamps. + */ +export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: number[]): boolean { + for (const sibling of openSiblingNumbers) { + if (sibling < prNumber) return false; + } + return true; +} + +/** + * True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's + * "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties + * between equally-ordered members use PR number. + */ +export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean { + if (openSiblings.length === 0) return true; + for (const sibling of openSiblings) { + if (!prPrecedesSibling(pr, sibling)) return false; + } + return true; +} + +/** + * True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH + * sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt` + * comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed + * legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison. + */ +function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean { + const prCreated = claimTimeMs(pr.createdAt); + const siblingCreated = claimTimeMs(sibling.createdAt); + if (prCreated !== null && siblingCreated !== null) { + if (prCreated !== siblingCreated) return prCreated < siblingCreated; + return pr.number <= sibling.number; + } + const prClaim = claimTimeMs(pr.linkedIssueClaimedAt); + if (prClaim === null) return false; + const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); + if (siblingClaim === null) return false; + if (siblingClaim < prClaim) return false; + if (siblingClaim === prClaim && sibling.number < pr.number) return false; + return true; +} + +/** + * The winning PR number among `pr` and its open duplicate siblings, or `null` when the election is not + * determinable (mirrors {@link isDuplicateClusterWinnerByClaim}'s fail-closed semantics — this never guesses a + * specific winner when the ordering data is too sparse/ambiguous to be sure). Used only for DISPLAY (naming the + * winner in a loser's close comment, #dup-winner-credit) — the close/hold decision for any given PR is still + * driven directly by {@link isDuplicateClusterWinnerByClaim}, not by this function's return value. + */ +export function resolveDuplicateClusterWinnerNumber(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): number | null { + if (isDuplicateClusterWinnerByClaim(pr, openSiblings)) return pr.number; + for (const sibling of openSiblings) { + const rest = openSiblings.filter((other) => other.number !== sibling.number); + if (isDuplicateClusterWinnerByClaim(sibling, [pr, ...rest])) return sibling.number; + } + return null; +} + +function claimTimeMs(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/packages/gittensory-engine/src/signals/predicted-gate-engine.ts b/packages/gittensory-engine/src/signals/predicted-gate-engine.ts new file mode 100644 index 0000000000..fc086bcafd --- /dev/null +++ b/packages/gittensory-engine/src/signals/predicted-gate-engine.ts @@ -0,0 +1,985 @@ +import type { + AdvisoryFinding, + BountyLifecycle, + BountyRecord, + CollisionCluster, + CollisionItem, + CollisionReport, + IssueQualityReport, + IssueRecord, + LaneAdvice, + PreflightInput, + PreflightResult, + PublicReadinessScore, + PullRequestRecord, + QueueHealth, + QueueSignalCounts, + RecentMergedPullRequestRecord, + RepositoryRecord, + SignalFinding, +} from "../types/predicted-gate-types.js"; +import { nowIso } from "../utils/json.js"; +import { PREFLIGHT_LIMITS } from "./preflight-limits.js"; +import { hasValidationNote, isTestPath } from "./test-evidence.js"; +import { diffFilePriority } from "../review/diff-file-priority.js"; + +export type { IssueQualityReport, CollisionReport, CollisionCluster } from "../types/predicted-gate-types.js"; + +const STOPWORDS = new Set([ + "the", + "and", + "for", + "with", + "from", + "this", + "that", + "when", + "into", + "issue", + "pull", + "request", + "add", + "fix", + "update", + "improve", +]); +const MAX_COLLISION_PAIRWISE_ISSUES = 80; +const MAX_COLLISION_PAIRWISE_PULL_REQUESTS = 120; +const MAX_COLLISION_PAIRWISE_RECENT_MERGES = 40; +const ISSUE_DISCOVERY_LIFECYCLE_REPORT_CAP = 300; +const ISSUE_QUALITY_REPORT_CAP = 100; +const REPO_OUTCOME_STALE_OPEN_DAYS = 30; +const REPO_OUTCOME_MIN_DECIDED_SAMPLE = 3; +const REPO_OUTCOME_MERGE_WELL_RATE = 0.7; +const REPO_OUTCOME_CLOSURE_RISK_RATE = 0.34; +const REPO_OUTCOME_MAX_PATTERNS = 12; + +export function buildLaneAdvice(repo: RepositoryRecord | null, fullName: string): LaneAdvice { + const config = repo?.registryConfig; + if (!repo || !repo.isRegistered || !config) { + return { + lane: "unknown", + repoFullName: fullName, + summary: "Repository registration is not available in the local Gittensory cache.", + contributorGuidance: "Do not assume this repo is ready for Gittensor-specific contribution guidance yet.", + maintainerGuidance: "Refresh the registry snapshot or install the GitHub App so Gittensory can evaluate the repo.", + }; + } + if (config.emissionShare <= 0) { + return { + lane: "inactive", + repoFullName: fullName, + issueDiscoveryShare: config.issueDiscoveryShare, + directPrShare: 0, + summary: "Repository is registered but has no active allocation in the current snapshot.", + contributorGuidance: "Treat this as normal upstream contribution work unless the registry changes.", + maintainerGuidance: "Do not expect Gittensor-driven contributor flow from this repo while allocation is zero.", + }; + } + const issueDiscoveryShare = clamp(config.issueDiscoveryShare, 0, 1); + const directPrShare = 1 - issueDiscoveryShare; + if (issueDiscoveryShare === 1) { + return { + lane: "issue_discovery", + repoFullName: fullName, + issueDiscoveryShare, + directPrShare, + summary: "Repository is configured for issue-discovery flow.", + contributorGuidance: "Focus on high-proof issue discovery and avoid self-resolved issue loops.", + maintainerGuidance: "Prioritize issue quality, duplicate risk, and whether reports are actionable for outside contributors.", + }; + } + if (issueDiscoveryShare === 0) { + return { + lane: "direct_pr", + repoFullName: fullName, + issueDiscoveryShare, + directPrShare, + summary: "Repository is configured for direct PR review.", + contributorGuidance: "Prefer focused PRs with clear evidence, linked context, and low review churn.", + maintainerGuidance: "Use PR hygiene, duplicate risk, and test evidence as the primary review filters.", + }; + } + return { + lane: "split", + repoFullName: fullName, + issueDiscoveryShare, + directPrShare, + summary: "Repository is configured for both issue discovery and direct PR review.", + contributorGuidance: "Pick one path intentionally: issue discovery for reports, direct PR for implementation.", + maintainerGuidance: "Check whether each submission is using the right path before reviewing technical detail.", + }; +} + +export function buildCollisionReport( + repoFullName: string, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], +): CollisionReport { + const openIssues = issues.filter((issue) => issue.state === "open"); + const openPullRequests = pullRequests.filter((pr) => pr.state === "open"); + const clusters = new Map(); + const pullRequestsByLinkedIssue = new Map(); + + for (const pr of openPullRequests) { + for (const issueNumber of pr.linkedIssues) { + const linkedPrs = pullRequestsByLinkedIssue.get(issueNumber) ?? []; + linkedPrs.push(pr); + pullRequestsByLinkedIssue.set(issueNumber, linkedPrs); + } + } + + for (const issue of openIssues) { + const linkedPrs = pullRequestsByLinkedIssue.get(issue.number) ?? []; + if (linkedPrs.length === 0) continue; + const items = [issueItem(issue), ...linkedPrs.map(prItem)]; + clusters.set(`issue-${issue.number}`, { + id: `issue-${issue.number}`, + risk: linkedPrs.length > 1 || issue.linkedPrs.length > 1 ? "high" : "medium", + reason: `Open PR work references issue #${issue.number}.`, + items, + }); + } + + const pairwiseIssues = boundedCollisionIssues(openIssues, openPullRequests); + const pairwisePullRequests = boundedCollisionPullRequests(openPullRequests); + const pairwiseRecentMergedPullRequests = recentMergedPullRequests.slice(0, MAX_COLLISION_PAIRWISE_RECENT_MERGES); + const items = [...pairwiseIssues.map(issueItem), ...pairwisePullRequests.map(prItem), ...pairwiseRecentMergedPullRequests.map(recentMergedItem)]; + const itemTerms = new Map(); + for (const item of items) itemTerms.set(itemKey(item), collisionTerms(item)); + for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) { + const left = items[leftIndex]; + const right = items[rightIndex]; + /* v8 ignore next -- Sparse array slots are defensive; collision items are built from bounded lists above. */ + if (!left || !right) continue; + /* v8 ignore start -- Collision items always carry linkedIssues arrays; nullish defaults are defensive only. */ + const sharedIssue = (left.linkedIssues ?? []).find((issue) => (right.linkedIssues ?? []).includes(issue)); + /* v8 ignore stop */ + if (sharedIssue) { + const key = [itemKey(left), itemKey(right)].sort().join("--"); + /* v8 ignore next -- Pairwise shared-issue clusters are covered by buildCollisionReport integration tests. */ + if (!clusters.has(key)) { + clusters.set(key, { + id: key, + risk: right.type === "recent_merged_pull_request" || left.type === "recent_merged_pull_request" ? "medium" : "high", + reason: `Items reference the same linked issue #${sharedIssue}.`, + items: [left, right], + }); + } + continue; + } + let leftTerms = itemTerms.get(itemKey(left)); + /* v8 ignore next -- Defensive only: every collision item is pre-indexed in itemTerms before this loop. */ + if (leftTerms === undefined) leftTerms = collisionTerms(left); + let rightTerms = itemTerms.get(itemKey(right)); + /* v8 ignore next -- Defensive only: every collision item is pre-indexed in itemTerms before this loop. */ + if (rightTerms === undefined) rightTerms = collisionTerms(right); + const overlap = termOverlap(leftTerms, rightTerms); + if (overlap.score < 0.58 || overlap.shared < 2) continue; + // Re-score without path terms: tells us whether title/label overlap ALONE already clears the bar + // (pre-existing behavior, unaffected) or whether changedFiles tokens are what pushed this pair over — + // the two false-positive shapes that creates are guarded separately below. + const titleOnlyOverlap = termOverlap(collisionTerms(left, false), collisionTerms(right, false)); + const pathDrivenMatch = titleOnlyOverlap.score < 0.58 || titleOnlyOverlap.shared < 2; + if (pathDrivenMatch) { + // A contributor iterating on their own work (e.g. a follow-up PR touching the same file as their + // still-open prior PR) is not duplicate effort — self-authored path-only overlap is dropped outright. + /* v8 ignore start -- Self-authored path-only overlap is covered by collision parity tests. */ + if (isPullRequestShapedItem(left) && isPullRequestShapedItem(right) && Boolean(left.authorLogin) && sameLogin(left.authorLogin, right.authorLogin ?? "")) { + continue; + } + /* v8 ignore stop */ + // Different authors: file paths tokenize into directory segments (src, review, test, unit, ...) that + // recur across nearly every PR in a consistently-organized repo, so shared TOKENS alone are not + // reliable collision evidence — a repo-wide shadow test found this drove the large majority of + // path-only matches with zero actual shared files. Require an ACTUAL shared file (ignoring + // lockfiles/generated artifacts nobody would call a collision over) before clustering. + if (!sharesMeaningfulFile(left.changedFiles, right.changedFiles)) continue; + } + const key = [itemKey(left), itemKey(right)].sort().join("--"); + /* v8 ignore next -- Duplicate pairwise keys cannot occur in a single nested-loop pass; this guard is defensive only. */ + if (clusters.has(key)) continue; + clusters.set(key, { + id: key, + risk: overlap.score >= 0.75 ? "high" : "medium", + reason: `Titles/paths share ${overlap.shared} meaningful terms.`, + items: [left, right], + }); + } + } + + const clusterList = [...clusters.values()].sort((left, right) => riskRank(right.risk) - riskRank(left.risk)); + const report = { + repoFullName, + generatedAt: nowIso(), + summary: { + clusterCount: clusterList.length, + highRiskCount: clusterList.filter((cluster) => cluster.risk === "high").length, + itemsReviewed: openIssues.length + openPullRequests.length + recentMergedPullRequests.length, + }, + clusters: clusterList, + }; + collisionReportTermCache.set(report, itemTerms); + return report; +} + +export function itemSharesPlannedLinkedIssue(item: CollisionItem, plannedLinkedIssues: number[]): boolean { + return (item.linkedIssues ?? []).some((issueNumber) => plannedLinkedIssues.includes(issueNumber)); +} + +export function buildQueueHealth( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + collisions: CollisionReport, + countOverrides: QueueSignalCounts = {}, +): QueueHealth { + const repoFullName = repo?.fullName ?? collisions.repoFullName; + const openIssues = issues.filter((issue) => issue.state === "open"); + const openPullRequests = pullRequests.filter((pr) => pr.state === "open"); + const openIssueCount = Math.max(openIssues.length, countOverrides.openIssues ?? 0); + const openPullRequestCount = Math.max(openPullRequests.length, countOverrides.openPullRequests ?? 0); + const likelyReviewablePullRequestsSource = + countOverrides.likelyReviewablePullRequests !== undefined ? "authoritative" : openPullRequestCount > openPullRequests.length ? "sampled_cache" : "cache"; + const unlinkedPullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length === 0); + const stalePullRequests = openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); + const draftPullRequests = openPullRequests.filter((pr) => pr.isDraft); + const maintainerAuthoredPullRequests = openPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation)); + const cachedLikelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length; + const likelyReviewablePullRequests = Math.min(openPullRequestCount, Math.max(cachedLikelyReviewablePullRequests, countOverrides.likelyReviewablePullRequests ?? 0)); + const ageBuckets = { + under7Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) < 7).length, + days7To30: openPullRequests.filter((pr) => { + const age = daysSince(pr.updatedAt ?? pr.createdAt); + return age >= 7 && age <= 30; + }).length, + over30Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) > 30).length, + }; + const burdenScore = clamp( + openPullRequestCount * 6 + + openIssueCount + + unlinkedPullRequests.length * 8 + + stalePullRequests.length * 6 + + ageBuckets.over30Days * 4 + + collisions.summary.clusterCount * 10 - + likelyReviewablePullRequests * 2, + 0, + 100, + ); + let level: QueueHealth["level"] = "low"; + if (burdenScore >= 80) level = "critical"; + else if (burdenScore >= 55) level = "high"; + else if (burdenScore >= 25) level = "medium"; + const findings: SignalFinding[] = []; + if (unlinkedPullRequests.length > 0) { + findings.push({ + code: "unlinked_prs", + severity: "warning", + title: "Open PRs are missing linked issue context", + detail: `${unlinkedPullRequests.length} open pull request(s) in the local cache do not reference a closing issue.`, + action: "Ask contributors to link relevant issues or explain no-issue PR intent clearly.", + }); + } + if (collisions.summary.clusterCount > 0) { + findings.push({ + code: "collision_clusters", + severity: collisions.summary.highRiskCount > 0 ? "warning" : "info", + title: "Duplicate or overlapping work is visible", + detail: `${collisions.summary.clusterCount} possible overlap cluster(s) were detected.`, + action: "Review overlapping submissions before spending detailed review time.", + }); + } + if (stalePullRequests.length > 0) { + findings.push({ + code: "stale_prs", + severity: "info", + title: "Some open PRs appear stale", + detail: `${stalePullRequests.length} open pull request(s) have not updated in at least 14 days.`, + }); + } + const inactiveDraftPullRequests = draftPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); + if (inactiveDraftPullRequests.length > 0) { + findings.push({ + code: "inactive_draft_prs", + severity: "info", + title: "Draft PRs have been open without recent activity", + detail: `${inactiveDraftPullRequests.length} draft pull request(s) have not updated in at least 14 days — they may be abandoned or blocked.`, + action: "Mark as ready for review when work resumes, or close if the approach has been abandoned.", + }); + } + return { + repoFullName, + generatedAt: nowIso(), + burdenScore, + level, + summary: `Queue burden is ${level} with ${openPullRequestCount} open PR(s), ${openIssueCount} open issue(s), and ${collisions.summary.clusterCount} overlap cluster(s).`, + signals: { + openIssues: openIssueCount, + openPullRequests: openPullRequestCount, + unlinkedPullRequests: unlinkedPullRequests.length, + stalePullRequests: stalePullRequests.length, + draftPullRequests: draftPullRequests.length, + maintainerAuthoredPullRequests: maintainerAuthoredPullRequests.length, + collisionClusters: collisions.summary.clusterCount, + ageBuckets, + likelyReviewablePullRequests, + cachedOpenPullRequests: openPullRequests.length, + likelyReviewablePullRequestsSource, + }, + findings, + }; +} + +export function buildPreflightResult( + input: PreflightInput, + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + bounties: BountyRecord[] = [], + issueQuality?: IssueQualityReport | null | undefined, + // Default true so every existing caller (which predates this param) keeps its exact prior behavior. + registryEverSynced = true, +): PreflightResult { + const lane = buildLaneAdvice(repo, input.repoFullName); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars), input.repoFullName)])].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)`, + // which only matched when an existing item's title contained the *entire* + // planned title — so a typical (longer, more descriptive) planned PR title + // never matched a shorter duplicate issue, silently suppressing the warning, + // while a short planned title spuriously matched unrelated items. Use the same + // symmetric term-overlap heuristic `buildCollisionReport` uses between items + // (>=2 shared meaningful terms), which is direction-independent. + const plannedTerms = plannedContributionTerms(input); + const collisionReport = buildCollisionReport(input.repoFullName, issues, pullRequests); + let cachedItemTerms = collisionReportTermCache.get(collisionReport); + /* v8 ignore next -- Defensive only: buildCollisionReport always seeds term maps before preflight reads them. */ + if (cachedItemTerms === undefined) cachedItemTerms = new Map(); + const itemTerms = cachedItemTerms; + const collisions = collisionReport.clusters.filter((cluster) => + cluster.items.some((item) => { + if (itemSharesPlannedLinkedIssue(item, linkedIssues)) { + return true; + } + const overlap = termOverlap(plannedTerms, (() => { + let terms = itemTerms.get(itemKey(item)); + /* v8 ignore next -- Defensive only: collision item terms are cached for every cluster item. */ + if (terms === undefined) terms = collisionTerms(item); + return terms; + })()); + return overlap.shared >= 2 && overlap.score >= 0.5; + }), + ); + const findings: SignalFinding[] = []; + // An "unknown" lane means "not found in the local registry cache", which is genuinely ambiguous: it's the + // same result whether this repo simply isn't registered in a WORKING snapshot, or the registry sync has + // never once succeeded (a self-host connectivity/config problem with no bearing on this PR at all). Only + // treat "unknown" as a real signal once we know the sync mechanism itself has produced at least one + // snapshot; "inactive" (zero emission share) is unambiguous either way -- it is only reachable from real + // synced data. + const laneUnavailable = (lane.lane === "unknown" && registryEverSynced) || lane.lane === "inactive"; + const maintainerAuthored = isMaintainerAssociation(input.authorAssociation); + if (laneUnavailable) { + findings.push({ + code: "lane_not_recommended", + severity: maintainerAuthored ? "info" : "warning", + title: maintainerAuthored ? "Repo lane unavailable for contributor scoring" : "Repo lane is not ready for a confident recommendation", + detail: maintainerAuthored ? `${lane.summary} Maintainer-authored work is treated as repo stewardship, not contributor-lane eligibility.` : lane.summary, + action: maintainerAuthored ? "No action." : "Refresh registry data or choose a registered active repo.", + }); + } + if (linkedIssues.length === 0 && lane.lane !== "issue_discovery") { + findings.push({ + code: "missing_linked_issue", + severity: "warning", + title: "No linked issue detected", + detail: "The planned PR does not reference a closing issue or explicit linked issue number.", + action: "Link the issue being solved, or explicitly explain why this is a no-issue PR.", + }); + } + if (collisions.length > 0) { + findings.push({ + code: "possible_duplicate_work", + /* v8 ignore next -- High-risk severity is covered through collision reports; info-only clusters are presentation fallback. */ + severity: collisions.some((cluster) => cluster.risk === "high") ? "warning" : "info", + title: "Possible duplicate or overlapping work", + detail: `${collisions.length} related open work cluster(s) were detected.`, + action: "Check active issues and PRs before submitting.", + }); + } + const bountyByIssue = indexBountiesByIssue(bounties); + for (const issueNumber of linkedIssues) { + const bounty = bountyByIssue.get(bountyIssueKey(input.repoFullName, issueNumber)); + if (!bounty) continue; + const linkedIssue = issues.find((candidate) => candidate.repoFullName.toLowerCase() === input.repoFullName.toLowerCase() && candidate.number === issueNumber) ?? null; + const lifecycle = classifyBountyLifecycle(bounty, linkedIssue); + if (isHistoricalBountyLifecycle(lifecycle)) { + findings.push({ + code: "linked_issue_bounty_historical", + severity: "info", + title: "Linked issue bounty is historical", + detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm the work is still wanted before investing in it.`, + action: "Verify the bounty and issue are still open upstream.", + }); + } else if (lifecycle === "stale") { + findings.push({ + code: "linked_issue_bounty_unverified", + severity: "warning", + title: "Linked issue bounty needs verification", + detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm it is still active before relying on it as contribution context.`, + action: "Re-check the upstream bounty source before submitting.", + }); + } else if (lifecycle === "ambiguous") { + findings.push({ + code: "linked_issue_bounty_unverified", + severity: "warning", + title: "Linked issue bounty needs verification", + detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm it is still active before relying on it as contribution context.`, + action: "Re-check the upstream bounty source before submitting.", + }); + } + } + findings.push(...issueQualityFindings(linkedIssues, issueQuality)); + const changedFiles = input.changedFiles ?? []; + const tests = input.tests ?? []; + if (changedFiles.some((file) => isCodeFile(file)) && tests.length === 0 && !changedFiles.some((file) => isTestFile(file))) { + findings.push({ + code: "missing_test_evidence", + severity: "warning", + title: "No test evidence supplied", + detail: "Code files are listed, but no tests or test files were supplied in preflight input.", + action: "Add focused test evidence or explain why existing coverage is sufficient.", + }); + } + const reviewBurden = changedFiles.length >= 12 || collisions.length > 0 ? "high" : changedFiles.length >= 5 ? "medium" : "low"; + const hasWarning = findings.some((finding) => finding.severity === "warning" || finding.severity === "critical"); + return { + repoFullName: input.repoFullName, + generatedAt: nowIso(), + status: laneUnavailable && !maintainerAuthored ? "hold" : hasWarning ? "needs_work" : "ready", + lane, + reviewBurden, + linkedIssues, + findings, + collisions, + }; +} + +function issueQualityFindings(linkedIssues: number[], issueQuality: IssueQualityReport | null | undefined): SignalFinding[] { + if (!issueQuality || linkedIssues.length === 0) return []; + const byIssue = new Map(issueQuality.issues.map((issue) => [issue.number, issue])); + return linkedIssues.flatMap((issueNumber) => { + const quality = byIssue.get(issueNumber); + if (!quality || quality.status === "ready") return []; + const detail = quality.warnings[0] ?? `Issue quality report marks #${issueNumber} as ${quality.status}.`; + if (quality.status === "do_not_use") { + return [ + { + code: "issue_quality_do_not_use", + severity: "warning" as const, + title: "Linked issue is already covered or duplicate-prone", + detail, + action: "Confirm the linked issue is still actionable before posting public PR context.", + }, + ]; + } + if (quality.status === "needs_proof") { + return [ + { + code: "issue_quality_needs_proof", + severity: "warning" as const, + title: "Linked issue needs stronger proof", + detail, + action: "Add concrete reproduction, scope, or maintainer context before proceeding.", + }, + ]; + } + return [ + { + code: "issue_quality_hold", + severity: "warning" as const, + title: "Linked issue is on hold", + detail, + action: "Choose a clearer candidate or wait for maintainer context.", + }, + ]; + }); +} + +export const BOUNTY_STALE_DAYS = 45; + +export function bountyIssueKey(repoFullName: string, issueNumber: number): string { + return `${repoFullName.toLowerCase()}#${issueNumber}`; +} + +export function indexBountiesByIssue(bounties: BountyRecord[]): Map { + const map = new Map(); + for (const bounty of bounties) { + map.set(bountyIssueKey(bounty.repoFullName, bounty.issueNumber), bounty); + } + return map; +} + +export function classifyBountyLifecycle(bounty: BountyRecord, issue: IssueRecord | null): BountyLifecycle { + const status = bounty.status.trim().toLowerCase(); + if (!status) return "unknown"; + if (/cancel|void|expired|withdrawn|rejected|abandon/.test(status)) return "cancelled"; + // Only past-tense payout phrasing (rewarded/awarded) marks completion; a bounty that merely + // advertises a "reward"/"award" is an active offer, not already-completed work. + if (/complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done/.test(status)) return "completed"; + if (/historical|archived|closed/.test(status)) return "historical"; + const looksActive = /open|active|live|available|ready|funded|reward|award|in[\s_-]?progress|todo|new/.test(status); + if (!looksActive) return "ambiguous"; + // Active-looking status: reconcile against the linked issue and freshness so dead context is not treated as live. + if (issue && issue.state !== "open") return "ambiguous"; + if (daysSince(bounty.updatedAt ?? bounty.discoveredAt) > BOUNTY_STALE_DAYS) return "stale"; + return "active"; +} + +export function isHistoricalBountyLifecycle(lifecycle: BountyLifecycle): boolean { + return lifecycle === "historical" || lifecycle === "completed" || lifecycle === "cancelled"; +} + +export function buildPublicReadinessScore(args: { + pr: PullRequestRecord; + preflight: PreflightResult; + queueHealth: QueueHealth; + linkedDuplicatePrs?: number[] | undefined; + scopedOverlapCount?: number | undefined; +}): PublicReadinessScore { + const linkedIssues = args.pr.linkedIssues; + const hasNoIssueRationale = hasClearNoIssueRationale(args.pr); + const linkedDuplicatePrs = args.linkedDuplicatePrs ?? []; + const scopedOverlapCount = args.scopedOverlapCount ?? 0; + const reviewLoadScore = reviewLoadComponentScore(args.preflight.reviewBurden); + const validation = validationComponent(args.pr, args.preflight); + const queuePressure = queuePressureComponent(args.queueHealth); + const components: PublicReadinessScore["components"] = [ + { + key: "traceability", + label: "Traceability", + score: linkedIssues.length > 0 || hasNoIssueRationale ? 15 : 8, + max: 15, + evidence: + linkedIssues.length > 0 + ? `Linked issue${linkedIssues.length === 1 ? "" : "s"} ${formatIssueRefs(linkedIssues)}.` + : hasNoIssueRationale + ? "PR body includes a no-issue rationale." + : "No linked issue or no-issue rationale found.", + action: linkedIssues.length > 0 || hasNoIssueRationale ? "No action." : "Explain no-issue PR.", + }, + { + key: "related_work", + label: "Related work", + score: linkedDuplicatePrs.length > 0 ? 8 : scopedOverlapCount > 0 ? 14 : 20, + max: 20, + evidence: + linkedDuplicatePrs.length > 0 + ? `Same linked issue with ${formatPrRefs(linkedDuplicatePrs)}.` + : scopedOverlapCount > 0 + ? `${Math.min(scopedOverlapCount, 3)} scoped overlap${Math.min(scopedOverlapCount, 3) === 1 ? "" : "s"} found.` + : "No active overlap found.", + action: linkedDuplicatePrs.length > 0 ? `Compare ${formatPrRefs(linkedDuplicatePrs)}.` : scopedOverlapCount > 0 ? "Review top overlaps." : "No action.", + }, + { + key: "change_scope", + label: "Change scope", + score: reviewLoadScore, + max: 20, + evidence: changeScopeEvidence(args.pr, args.preflight.reviewBurden), + action: reviewLoadScore >= 18 ? "No action." : "Add a concise scope and risk note.", + }, + { + key: "validation", + label: "Validation posture", + score: validation.score, + max: 25, + evidence: validation.evidence, + action: validation.action, + }, + { + key: "pr_state", + label: "PR state", + score: args.pr.state === "open" && !args.pr.isDraft ? 10 : args.pr.state === "open" ? 6 : 3, + max: 10, + evidence: args.pr.isDraft ? "PR is open as draft." : `PR state is ${args.pr.state}.`, + action: args.pr.state === "open" && !args.pr.isDraft ? "No action." : args.pr.isDraft ? "Mark ready when done." : "No action.", + }, + { + key: "queue_pressure", + label: "Review queue context", + score: queuePressure.score, + max: queuePressure.max, + evidence: queuePressure.evidence, + action: queuePressure.action, + }, + ]; + return { + total: clamp( + components.reduce((sum, component) => sum + component.score, 0), + 0, + 100, + ), + components, + }; +} + +function pullRequestSpecificCollisionClusters(report: CollisionReport, pr: PullRequestRecord): CollisionCluster[] { + return report.clusters.filter((cluster) => cluster.items.some((item) => item.type === "pull_request" && item.number === pr.number)); +} + +/** Deduplicated union of PR-specific collision clusters and preflight overlap clusters. */ +export function unionScopedOverlapClusters( + report: CollisionReport, + pr: PullRequestRecord, + preflightCollisions: CollisionCluster[], +): CollisionCluster[] { + const prCollisionClusters = pullRequestSpecificCollisionClusters(report, pr); + return [...new Map([...prCollisionClusters, ...preflightCollisions].map((cluster) => [cluster.id, cluster])).values()]; +} + +function sanitizePanelText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function changeScopeEvidence(pr: PullRequestRecord, reviewBurden: PreflightResult["reviewBurden"]): string { + const burden = reviewBurden === "low" ? "Low" : reviewBurden === "medium" ? "Medium" : "High"; + const sizeLabel = pr.labels.find((label) => /^size[:/-]/i.test(label)); + const detailParts = [ + sizeLabel ? `size label ${sanitizePanelText(sizeLabel)}` : undefined, + pr.isDraft ? "draft PR" : undefined, + pr.linkedIssues.length > 0 ? `${pr.linkedIssues.length} linked issue${pr.linkedIssues.length === 1 ? "" : "s"}` : "no linked issue context", + ].filter(Boolean); + return `${burden} review scope from cached public metadata (${detailParts.join("; ")}).`; +} + +function reviewLoadComponentScore(reviewBurden: PreflightResult["reviewBurden"]): number { + if (reviewBurden === "low") return 20; + if (reviewBurden === "medium") return 14; + return 8; +} + +function validationComponent(pr: PullRequestRecord, preflight: PreflightResult): { score: number; evidence: string; action: string } { + const findingCodes = preflight.findings.map((finding) => finding.code); + const missingTests = findingCodes.some((code) => /missing.*test|test.*missing|no_test/i.test(code)); + const explicitValidation = hasValidationNote(pr.body ?? ""); + if (preflight.status === "hold") { + return { score: 5, evidence: "Preflight is holding this PR: the review lane is unavailable, so it is not ready for automated review.", action: "Await review-lane availability." }; + } + if (missingTests) { + // A body validation note is an UNBACKED claim when no test files accompany the change. Cap it just above the + // no-signal floor so a one-line "tested" cannot lift readiness over a configured gate threshold on a + // zero-test PR — full credit is reserved for actual test evidence in the branch below. (#audit-2.3) + return explicitValidation + ? { score: 12, evidence: "PR body claims validation but no test files accompany the change.", action: "Add tests covering the change." } + : { score: 10, evidence: "No cached test files or validation note found.", action: "Add tests or validation evidence." }; + } + if (explicitValidation) { + return { score: 25, evidence: "PR body includes validation/test evidence.", action: "No action." }; + } + if (preflight.status === "ready") { + return { score: 20, evidence: "Preflight is ready, but the PR body does not name the validation run.", action: "Add validation command/output." }; + } + return { score: 12, evidence: "Preflight needs author follow-up before maintainer review.", action: "Address findings or add validation evidence." }; +} + +function queuePressureComponent(queueHealth: QueueHealth): { score: number; max: 10; evidence: string; action: string } { + const signals = queueHealth.signals; + const openPullRequests = Math.max(0, signals.openPullRequests); + const cachedOpenPullRequests = Math.max(0, signals.cachedOpenPullRequests ?? signals.ageBuckets.under7Days + signals.ageBuckets.days7To30 + signals.ageBuckets.over30Days); + const likelyReviewablePullRequests = Math.max(0, Math.min(openPullRequests, signals.likelyReviewablePullRequests)); + const sampledLikelyReviewable = signals.likelyReviewablePullRequestsSource === "sampled_cache" || (signals.likelyReviewablePullRequestsSource === undefined && cachedOpenPullRequests < openPullRequests); + const score = queuePressureScore(openPullRequests); + const likelyEvidence = + openPullRequests === 0 + ? "0 likely reviewable" + : sampledLikelyReviewable + ? cachedOpenPullRequests > 0 + ? `${likelyReviewablePullRequests} likely reviewable in ${cachedOpenPullRequests} cached PR(s); full queue reviewability is sampled` + : "likely-reviewable count unavailable from cached PR metadata" + : `${likelyReviewablePullRequests} likely reviewable`; + const detailParts = [ + `${openPullRequests} open PR(s)`, + likelyEvidence, + signals.stalePullRequests > 0 ? `${signals.stalePullRequests} stale` : undefined, + signals.unlinkedPullRequests > 0 ? `${signals.unlinkedPullRequests} unlinked` : undefined, + ].filter(Boolean); + return { + score, + max: 10, + evidence: `Repo queue: ${detailParts.join(", ")}.`, + action: score >= 8 ? "No action." : "Triage stale or unlinked PRs.", + }; +} + +function queuePressureScore(openPullRequests: number): number { + if (openPullRequests === 0) return 10; + return queuePressureOpenPullRequestScore(openPullRequests); +} + +function queuePressureOpenPullRequestScore(openPullRequests: number): number { + if (openPullRequests <= 4) return 10; + if (openPullRequests <= 8) return 8; + if (openPullRequests <= 13) return 5; + return 3; +} + +export function hasClearNoIssueRationale(pr: Pick): boolean { + // `docs?[\s-]+only` matches the space form ("docs only") AND the hyphenated "docs-only" / "doc-only" + // spelling this function's own docstring uses — the dominant GitHub/Conventional-Commits form. A bare + // `docs? only` missed the hyphen, so a docs-only PR with no linked issue was wrongly denied a clear + // no-issue rationale and hard-blocked under `linkedIssueGateMode === "block"`. + // `tests?[\s-]+only` extends the same rule to test-only PRs (regression/coverage-only diffs) — parallel + // to the docs-only hyphenation fix merged in #1905 and the test-only follow-up in #1993. + // `ci[\s-]+only` covers CI/workflow-only PRs using the same Conventional Commits spelling. + // `refactor[\s-]+only` covers internal refactors with no behavior change using the same spelling. + return /\b(?:no issue\s*(?:because\b|:)|no linked issue\s*(?:because\b|:)|no ticket\s*(?:because\b|:)|(?:maintenance|docs?[\s-]+only|tests?[\s-]+only|ci[\s-]+only|refactor[\s-]+only|typo|chore|cleanup)\b)/i.test([pr.title, pr.body ?? ""].join(" ")); +} + +function formatPrRefs(numbers: number[]): string { + return numbers.map((number) => `#${number}`).join(", "); +} + +function formatIssueRefs(numbers: number[]): string { + return numbers.map((number) => `#${number}`).join(", "); +} + +function issueItem(issue: IssueRecord): CollisionItem { + return { + type: "issue", + number: issue.number, + title: issue.title, + authorLogin: issue.authorLogin, + htmlUrl: issue.htmlUrl, + labels: issue.labels, + linkedIssues: [issue.number], + body: issue.body, + }; +} + +function prItem(pr: PullRequestRecord): CollisionItem { + return { + type: "pull_request", + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + htmlUrl: pr.htmlUrl, + labels: pr.labels, + linkedIssues: pr.linkedIssues, + linkedIssueClaimedAt: pr.linkedIssueClaimedAt, + changedFiles: pr.changedFiles, + body: pr.body, + }; +} + +function recentMergedItem(pr: RecentMergedPullRequestRecord): CollisionItem { + return { + type: "recent_merged_pull_request", + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + htmlUrl: pr.htmlUrl, + labels: pr.labels, + linkedIssues: pr.linkedIssues, + changedFiles: pr.changedFiles, + }; +} + +function itemKey(item: CollisionItem): string { + return `${item.type}-${item.number}`; +} + +function boundedCollisionIssues(openIssues: IssueRecord[], openPullRequests: PullRequestRecord[]): IssueRecord[] { + /* v8 ignore start -- Large-queue sampling is a deterministic guard; standard and linked collision paths are covered above. */ + if (openIssues.length <= MAX_COLLISION_PAIRWISE_ISSUES) return openIssues; + const linkedIssueNumbers = new Set(openPullRequests.flatMap((pr) => pr.linkedIssues)); + const selected = new Map(); + for (const issue of openIssues) { + if (linkedIssueNumbers.has(issue.number)) selected.set(issue.number, issue); + if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES) return [...selected.values()]; + } + for (const issue of openIssues) { + selected.set(issue.number, issue); + if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES) break; + } + return [...selected.values()]; + /* v8 ignore stop */ +} + +function boundedCollisionPullRequests(openPullRequests: PullRequestRecord[]): PullRequestRecord[] { + /* v8 ignore start -- Large-queue PR sampling mirrors boundedCollisionIssues; linked and pairwise collision paths are covered above. */ + if (openPullRequests.length <= MAX_COLLISION_PAIRWISE_PULL_REQUESTS) return openPullRequests; + // Rank linked-issue PRs ahead of unlinked ones, then by recency within each group, so the cap keeps + // the most-relevant PRs even when linked PRs alone exceed the budget (not just whichever appear + // first in caller order). + const ranked = [...openPullRequests].sort( + (left, right) => + Number(left.linkedIssues.length === 0) - Number(right.linkedIssues.length === 0) || + (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") || + left.number - right.number, + ); + return ranked.slice(0, MAX_COLLISION_PAIRWISE_PULL_REQUESTS); + /* v8 ignore stop */ +} + +export type CollisionTerms = { + terms: Set; + size: number; +}; + +const collisionReportTermCache = new WeakMap>(); + +function collisionTerms(item: CollisionItem, includePaths = true): CollisionTerms { + const terms = new Set(tokenize(collisionItemText(item, includePaths))); + return { terms, size: terms.size }; +} + +/** + * Tokenized terms for the planned contribution, used to detect overlap with + * existing open work. Mirrors `collisionTerms` so the planned PR is compared to + * collision items with the same term-overlap heuristic `buildCollisionReport` + * uses between items, rather than a one-direction substring test. + */ +function plannedContributionTerms(input: PreflightInput): CollisionTerms { + 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 }; +} + +export function termOverlap(left: CollisionTerms, right: CollisionTerms): { score: number; shared: number } { + if (left.size === 0) return { score: 0, shared: 0 }; + if (right.size === 0) return { score: 0, shared: 0 }; + let shared = 0; + const [smaller, larger] = left.size <= right.size ? [left.terms, right.terms] : [right.terms, left.terms]; + for (const term of smaller) { + if (larger.has(term)) shared += 1; + } + return { score: shared / Math.min(left.size, right.size), shared }; +} + +function collisionItemText(item: CollisionItem, includePaths = true): string { + return [ + truncateText(item.title, PREFLIGHT_LIMITS.titleChars), + ...boundedTextItems(item.labels, PREFLIGHT_LIMITS.labels, PREFLIGHT_LIMITS.labelChars), + ...(includePaths ? 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 { + if (value.length <= maxChars) return value; + return value.slice(0, maxChars); +} + +// Exported (#3183) so the project/milestone text matcher (src/integrations/project-tracker-adapter.ts) can +// reuse the exact same term-overlap heuristic already proven here for duplicate-PR collision detection, rather +// than re-implementing a second, subtly different tokenizer. +export function tokenize(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter((term) => term.length > 2 && !STOPWORDS.has(term)); +} + +function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] { + const numbers = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)].map((match) => Number(match[1])); + // GitHub also auto-closes via the fully-qualified `KEYWORD owner/repo#N` form (e.g. Renovate/Dependabot bodies). + // Count it only when owner/repo case-insensitively equals THIS repo — a reference to a different repo closes an + // issue elsewhere, not here, so it must not spoof a same-repo link. Same `\b`-anchored keywords as above (#1988). + const target = repoFullName.toLowerCase(); + for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([\w.-]+\/[\w.-]+)#(\d+)\b/gi)) { + if (match[1]!.toLowerCase() === target) numbers.push(Number(match[2])); + } + return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))]; +} + +function isMaintainerAssociation(value: string | null | undefined): boolean { + return value === "OWNER" || value === "MEMBER" || value === "COLLABORATOR"; +} + +function sameLogin(value: string | null | undefined, login: string): boolean { + return value?.toLowerCase() === login.toLowerCase(); +} + +function isPullRequestShapedItem(item: CollisionItem): boolean { + return item.type === "pull_request" || item.type === "recent_merged_pull_request"; +} + +/** True when two changed-file lists share at least one path that isn't a lockfile/generated/vendor artifact + * (diffFilePriority's least-useful-to-review bucket) — a shared package-lock.json or dist/ output is touched + * incidentally by unrelated PRs and is not evidence of a real collision. */ +function sharesMeaningfulFile(left: string[] | undefined, right: string[] | undefined): boolean { + if (!left || !right) return false; + if (left.length === 0 || right.length === 0) return false; + const rightSet = new Set(right); + return left.some((path) => rightSet.has(path) && diffFilePriority(path) < 4); +} + +function daysSince(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + /* v8 ignore next -- Invalid provider timestamps normalize to fresh; stale timestamp handling is covered by signal tests. */ + if (!Number.isFinite(parsed)) return 0; + return Math.floor((Date.now() - parsed) / 86_400_000); +} + + +function isCodeFile(file: string): boolean { + // Mirrors isCodeFile in local-branch.ts — kept in sync (cs/swift/groovy/php and C/C++/Objective-C added + // so native/C#/Swift/Groovy/PHP source counts as code, matching the test conventions + // isTestPath already recognizes; vue/svelte/astro match rag.ts, visual paths, and isCodePath; + // cc/hpp complete the C++ extension set alongside cpp/c/h; dart matches rag.ts and + // test-evidence's *_test.dart test convention). + return ( + /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs|kt|scala|java|go|sql|cs|swift|groovy|php|cpp|cc|c|h|hpp|m|vue|svelte|astro|dart)$/i.test( + file, + ) && !isTestFile(file) + ); +} + +function isTestFile(file: string): boolean { + // Single-sourced with the canonical matcher (test-evidence.ts isTestPath), mirroring local-branch.ts's + // isTestFile — so cy/e2e, __snapshots__, and module extensions stay in sync and can't drift. + return isTestPath(file); +} + +function riskRank(risk: CollisionCluster["risk"]): number { + if (risk === "high") return 3; + /* v8 ignore next -- Low collision rank is the default branch; high/medium sorting behavior is covered by collision tests. */ + if (risk === "medium") return 2; + /* v8 ignore next -- Collision clusters are only assigned medium/high risk today; low is the unreachable default. */ + return 1; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** @internal Exported for unit tests of predicted-gate engine helpers. */ +export const predictedGateEngineInternals = { + sharesMeaningfulFile, + truncateText, + extractLinkedIssueNumbers, + changeScopeEvidence, + reviewLoadComponentScore, + validationComponent, + queuePressureComponent, + queuePressureOpenPullRequestScore, +}; diff --git a/packages/gittensory-engine/src/signals/preflight-limits.ts b/packages/gittensory-engine/src/signals/preflight-limits.ts new file mode 100644 index 0000000000..dfefd36faf --- /dev/null +++ b/packages/gittensory-engine/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/packages/gittensory-engine/src/signals/test-evidence.ts b/packages/gittensory-engine/src/signals/test-evidence.ts new file mode 100644 index 0000000000..bd4c41a4f6 --- /dev/null +++ b/packages/gittensory-engine/src/signals/test-evidence.ts @@ -0,0 +1,35 @@ +export function isTestPath(file: string): boolean { + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb|dart)$/i.test(file) || + /(^|\/)test_[^/]*\.py$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs)$/i.test(file) || + /(^|\/)[^/]+\.(cy|e2e)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file) || + /(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy|php)$/.test(file) || + /(^|\/)__snapshots__\//i.test(file) + ); +} + +const TEST_STEM = "(?:test(?:ed|s|ing)?|validat(?:ion|ed)|verif(?:y|ied|ying)|manual check|smoke(?:\\s+tests?)?)"; +const NEGATION_WORD = "(?:no|not|never|without|skip(?:ped)?|didn't|doesn't|isn't|wasn't|weren't|haven't|hasn't)"; +const NEGATION_CONTINUATION = "(?:not|never|failed|failing|skipped|incomplete)"; +const SAME_SENTENCE_FILLER_WORD = "[^\\s.,!?;]+"; +const LABEL_SEPARATOR_GAP = "(?:\\s+|[:;\\-\\u2013\\u2014]\\s*)"; +const NEGATES_BEFORE_TEST_STEM = new RegExp(`\\b${NEGATION_WORD}\\b${LABEL_SEPARATOR_GAP}(?:${SAME_SENTENCE_FILLER_WORD}\\s+){0,3}${TEST_STEM}\\b`, "i"); +const NEGATES_AFTER_TEST_STEM = new RegExp(`\\b${TEST_STEM}\\b${LABEL_SEPARATOR_GAP}(?:${SAME_SENTENCE_FILLER_WORD}\\s+){0,2}${NEGATION_CONTINUATION}\\b`, "i"); +const NEGATES_TEST_STEM_PREFIX = /\bun(?:tested|validated|verified)\b/i; +const AFFIRMATIVE_TEST_MENTION = /\b(test(?:ed|s|ing)?|validation|validated|verified|manual check|smoke|pytest|vitest|npm test|pnpm test|cargo test|go test)\b/i; + +export function hasValidationNote(value: string): boolean { + return value + .split(/[.,!?]+/) + .some( + (clause) => + !NEGATES_TEST_STEM_PREFIX.test(clause) && + !NEGATES_BEFORE_TEST_STEM.test(clause) && + !NEGATES_AFTER_TEST_STEM.test(clause) && + AFFIRMATIVE_TEST_MENTION.test(clause), + ); +} diff --git a/packages/gittensory-engine/src/types/predicted-gate-types.ts b/packages/gittensory-engine/src/types/predicted-gate-types.ts new file mode 100644 index 0000000000..a3091ff056 --- /dev/null +++ b/packages/gittensory-engine/src/types/predicted-gate-types.ts @@ -0,0 +1,375 @@ +// Local mirrors from src/types.ts, src/signals/engine.ts, and src/signals/focus-manifest.ts. +// Keep in sync by hand — the engine package cannot import across into src/. + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type RegistryRepoConfig = { + repo: string; + emissionShare: number; + issueDiscoveryShare: number; + labelMultipliers: Record; + trustedLabelPipeline?: boolean | null; + maintainerCut: number; + defaultLabelMultiplier?: number | null; + fixedBaseScore?: number | null; + eligibilityMode?: string | null; + raw: Record; +}; + +export type AdvisoryConclusion = "success" | "neutral" | "action_required"; +export type AdvisorySeverity = "info" | "warning" | "critical"; + +export type AdvisoryFinding = { + code: string; + title: string; + severity: AdvisorySeverity; + detail: string; + action?: string; + publicText?: string; + confidence?: number; +}; + +export type Advisory = { + id: string; + targetType: "repository" | "pull_request" | "issue"; + targetKey: string; + repoFullName: string; + pullNumber?: number; + issueNumber?: number; + headSha?: string; + conclusion: AdvisoryConclusion; + severity: AdvisorySeverity; + title: string; + summary: string; + findings: AdvisoryFinding[]; + generatedAt: string; +}; + +export type RepositoryRecord = { + fullName: string; + owner: string; + name: string; + installationId?: number | null | undefined; + isInstalled: boolean; + isRegistered: boolean; + isPrivate: boolean; + htmlUrl?: string | null | undefined; + defaultBranch?: string | null | undefined; + registryConfig?: RegistryRepoConfig | null | undefined; +}; + +export type PullRequestRecord = { + repoFullName: string; + number: number; + title: string; + state: string; + authorLogin?: string | null | undefined; + authorAssociation?: string | null | undefined; + headSha?: string | null | undefined; + headRef?: string | null | undefined; + baseRef?: string | null | undefined; + htmlUrl?: string | null | undefined; + mergedAt?: string | null | undefined; + isDraft?: boolean | null | undefined; + mergeableState?: string | null | undefined; + reviewDecision?: string | null | undefined; + body?: string | null | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; + closedAt?: string | null | undefined; + linkedIssueClaimedAt?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + slopRisk?: number | null | undefined; + slopBand?: string | null | undefined; + mergeAttemptCount?: number | null | undefined; + mergeBlockedSha?: string | null | undefined; + mergeBlockedReason?: string | null | undefined; + approvedHeadSha?: string | null | undefined; + lastRegatedAt?: string | null | undefined; + lastPublishedSurfaceSha?: string | null | undefined; + changedFiles?: string[] | undefined; +}; + +export type IssueRecord = { + repoFullName: string; + number: number; + title: string; + state: string; + authorLogin?: string | null | undefined; + authorAssociation?: string | null | undefined; + htmlUrl?: string | null | undefined; + body?: string | null | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; + closedAt?: string | null | undefined; + labels: string[]; + linkedPrs: number[]; +}; + +export type BountyRecord = { + id: string; + repoFullName: string; + issueNumber: number; + status: string; + amountText?: string | null | undefined; + sourceUrl?: string | null | undefined; + payload: Record; + discoveredAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type GateRuleMode = "off" | "advisory" | "block"; +export type GatePolicyPack = "gittensor" | "oss-anti-slop"; + +export type RepositorySettings = { + repoFullName: string; + hardGuardrailGlobs?: string[] | null | undefined; +}; + +export type RecentMergedPullRequestRecord = { + repoFullName: string; + number: number; + title: string; + authorLogin?: string | null | undefined; + htmlUrl?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + changedFiles?: string[] | undefined; +}; + +export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; +export type SignalFinding = AdvisoryFinding; + +export type LaneAdvice = { + lane: ParticipationLane; + repoFullName: string; + issueDiscoveryShare?: number | undefined; + directPrShare?: number | undefined; + summary: string; + contributorGuidance: string; + maintainerGuidance: string; +}; + +export type CollisionItem = { + type: "issue" | "pull_request" | "recent_merged_pull_request"; + number: number; + title: string; + authorLogin?: string | null | undefined; + htmlUrl?: string | null | undefined; + labels?: string[] | undefined; + linkedIssues?: number[] | undefined; + linkedIssueClaimedAt?: string | null | undefined; + changedFiles?: string[] | undefined; + body?: string | null | undefined; +}; + +export type CollisionCluster = { + id: string; + risk: "low" | "medium" | "high"; + reason: string; + items: CollisionItem[]; +}; + +export type CollisionReport = { + repoFullName: string; + generatedAt: string; + summary: { + clusterCount: number; + highRiskCount: number; + itemsReviewed: number; + }; + clusters: CollisionCluster[]; +}; + +export type QueueHealth = { + repoFullName: string; + generatedAt: string; + burdenScore: number; + level: "low" | "medium" | "high" | "critical"; + summary: string; + signals: { + openIssues: number; + openPullRequests: number; + unlinkedPullRequests: number; + stalePullRequests: number; + draftPullRequests: number; + maintainerAuthoredPullRequests: number; + collisionClusters: number; + ageBuckets: { + under7Days: number; + days7To30: number; + over30Days: number; + }; + likelyReviewablePullRequests: number; + cachedOpenPullRequests?: number | undefined; + likelyReviewablePullRequestsSource?: "cache" | "sampled_cache" | "authoritative" | undefined; + }; + findings: AdvisoryFinding[]; +}; + +export type QueueSignalCounts = { + openIssues?: number | undefined; + openPullRequests?: number | undefined; + likelyReviewablePullRequests?: number | undefined; +}; + +export type PreflightInput = { + repoFullName: string; + contributorLogin?: string | undefined; + title: string; + body?: string | undefined; + labels?: string[] | undefined; + changedFiles?: string[] | undefined; + linkedIssues?: number[] | undefined; + tests?: string[] | undefined; + authorAssociation?: string | undefined; +}; + +export type PreflightResult = { + repoFullName: string; + generatedAt: string; + status: "ready" | "needs_work" | "hold"; + lane: LaneAdvice; + reviewBurden: "low" | "medium" | "high"; + linkedIssues: number[]; + findings: SignalFinding[]; + collisions: CollisionCluster[]; +}; + +export type PublicReadinessScore = { + total: number; + components: Array<{ + key: "traceability" | "related_work" | "change_scope" | "validation" | "pr_state" | "queue_pressure"; + label: string; + score: number; + max: number; + evidence: string; + action: string; + }>; +}; + +export type IssueQualityReport = { + repoFullName: string; + generatedAt: string; + lane: LaneAdvice; + issues: Array<{ + number: number; + title: string; + status: "ready" | "needs_proof" | "hold" | "do_not_use"; + score: number; + reasons: string[]; + warnings: string[]; + }>; + summary: string; +}; + +export type BountyLifecycle = "active" | "historical" | "completed" | "cancelled" | "stale" | "ambiguous" | "unknown"; + +export type FocusManifestSource = "repo_file" | "api_record" | "none"; +export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; +export type FocusManifestIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; +export type ReviewCheckMode = "required" | "visible" | "disabled"; +export type CombineStrategy = "single" | "consensus" | "synthesis"; +export type OnMerge = "either" | "both"; + +export type FocusManifestGateConfig = { + present: boolean; + enabled: boolean | null; + checkMode: ReviewCheckMode | null; + pack: GatePolicyPack | null; + linkedIssue: GateRuleMode | null; + duplicates: GateRuleMode | null; + readinessMode: GateRuleMode | null; + readinessMinScore: number | null; + slopMode: GateRuleMode | null; + slopMinScore: number | null; + slopAiAdvisory: boolean | null; + sizeMode: GateRuleMode | null; + lockfileIntegrityMode: GateRuleMode | null; + aiReviewMode: GateRuleMode | null; + aiReviewByok: boolean | null; + aiReviewProvider: "anthropic" | "openai" | null; + aiReviewModel: string | null; + aiReviewAllAuthors: boolean | null; + aiReviewCloseConfidence: number | null; + aiReviewCombine: CombineStrategy | null; + aiReviewOnMerge: OnMerge | null; + aiReviewReviewers: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null; + mergeReadiness: GateRuleMode | null; + manifestPolicy: GateRuleMode | null; + selfAuthoredLinkedIssue: GateRuleMode | null; + dryRun: boolean | null; + firstTimeContributorGrace: boolean | null; + premergeContentRecheck: boolean | null; + requireFreshRebaseWindowMinutes: number | null; + claMode: GateRuleMode | null; + claConsentPhrase: string | null; + claCheckRunName: string | null; + claCheckRunAppSlug: string | null; + expectedCiContexts: ReadonlyArray | null; +}; + +export type PreMergeCheck = { + name: string; + whenPaths: string[]; + titleContains: string | null; + descriptionContains: string | null; + requireLabel: string | null; + enforce: boolean; +}; + +export type FocusManifestReviewConfig = { + present: boolean; + preMergeChecks: PreMergeCheck[]; +}; + +export type FocusManifestSettings = { + hardGuardrailGlobs?: string[] | null | undefined; +}; + +export type FocusManifest = { + present: boolean; + source: FocusManifestSource; + wantedPaths: string[]; + preferredLabels: string[]; + linkedIssuePolicy: FocusManifestLinkedIssuePolicy; + testExpectations: string[]; + issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; + maintainerNotes: string[]; + publicNotes: string[]; + gate: FocusManifestGateConfig; + settings: FocusManifestSettings; + review: FocusManifestReviewConfig; + warnings: string[]; +}; + +export type FocusManifestFinding = { + code: + | "manifest_off_focus" + | "manifest_preferred_path" + | "manifest_missing_preferred_label" + | "manifest_linked_issue_required" + | "manifest_linked_issue_preferred" + | "manifest_missing_tests" + | "manifest_issue_discovery_discouraged" + | "manifest_malformed"; + severity: "info" | "warning" | "critical"; + title: string; + detail: string; + action?: string | undefined; +}; + +export type FocusManifestGuidance = { + present: boolean; + source: FocusManifestSource; + linkedIssuePolicy: FocusManifestLinkedIssuePolicy; + issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; + matchedWantedPaths: string[]; + preferredLabelHits: string[]; + findings: FocusManifestFinding[]; + publicNextSteps: string[]; + warnings: string[]; + summary: string; +}; diff --git a/packages/gittensory-engine/src/utils/json.ts b/packages/gittensory-engine/src/utils/json.ts new file mode 100644 index 0000000000..958d9ada7c --- /dev/null +++ b/packages/gittensory-engine/src/utils/json.ts @@ -0,0 +1,3 @@ +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/gittensory-engine/tsconfig.json b/packages/gittensory-engine/tsconfig.json index 1e17f2f009..85aa27d3f8 100644 --- a/packages/gittensory-engine/tsconfig.json +++ b/packages/gittensory-engine/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", - "types": [], + "types": ["node"], "declaration": true, "rootDir": "src", "outDir": "dist", diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index a4a1c30042..853eeec11f 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -1,252 +1 @@ -import { - buildCollisionReport, - buildPreflightResult, - buildPublicReadinessScore, - buildQueueHealth, - unionScopedOverlapClusters, - type IssueQualityReport, -} from "../signals/engine"; -import { buildFocusManifestGuidance, type FocusManifest } from "../signals/focus-manifest"; -import { guardrailPathMatches, isGuardrailHit } from "../signals/change-guardrail"; -import { resolveHardGuardrailGlobs } from "../review/guardrail-config"; -import { sanitizePublicComment } from "../github/commands"; -import { GITTENSOR_HOME_URL } from "../github/footer"; -import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types"; - -// Opt-in funnel (#694): a non-Gittensor adopter running the `oss-anti-slop` pack learns that Gittensor pays -// contributors for OSS work like this. Public-safe "earn" wording only (never reward/payout/score). -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 } from "./advisory"; -import { hasValidationNote, isTestPath } from "../signals/test-evidence"; -import { evaluateClaCheck } from "../review/cla-check"; -import { evaluatePreMergeChecks } from "../review/pre-merge-checks"; - -// PredictedGateVerdict/PredictedGateInput and the predictedGateNote/publicSafeFinding pure helpers now live in -// `@jsonbored/gittensory-engine` (#2276) so a miner can model the gate locally with the same shapes; -// buildPredictedGateVerdict itself follows in the keystone issue (#2283). Imported via the relative source path -// — this repo's engine-consumption convention (see src/scoring/preview.ts) — so `typecheck`/`test:coverage` do -// not depend on the engine's built `dist/`, which is not guaranteed present when they run in CI. -import { - predictedGateNote, - publicSafeFinding as buildPublicSafeFinding, -} from "../../packages/gittensory-engine/src/predicted-gate"; -import type { PredictedGateInput, PredictedGateVerdict } from "../../packages/gittensory-engine/src/predicted-gate"; - -// Re-export the types so existing importers (test fixtures, engine-parity suites) keep resolving them here. -export type { PredictedGateInput, PredictedGateVerdict }; - -// publicSafeFinding lives in the engine but takes the redaction fn as an argument so the engine stays isolated from -// src/github/commands' sanitizePublicComment. Bind the canonical sanitizer here so the call sites below are unchanged. -const publicSafeFinding = (finding: { code: string; title: string; detail: string; action?: string | undefined }) => - buildPublicSafeFinding(finding, sanitizePublicComment); - -/** GitHub full names are case-insensitive — mirror `sameRepo` in the live gate paths. */ -function sameRepoFullName(left: string | null | undefined, right: string | null | undefined): boolean { - return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); -} - -export function buildPredictedGateVerdict(args: { - input: PredictedGateInput; - manifest: FocusManifest; - repo: RepositoryRecord | null; - issues: IssueRecord[]; - pullRequests: PullRequestRecord[]; - bounties?: BountyRecord[] | undefined; - issueQuality?: IssueQualityReport | null | undefined; - /** The contributor's OWN confirmed-Gittensor status (self-data). Carried through for transparency only — - * 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( - { - repoFullName: input.repoFullName, - contributorLogin: input.contributorLogin, - title: input.title, - body: input.body, - labels: input.labels, - linkedIssues: input.linkedIssues, - authorAssociation: input.authorAssociation, - }, - repo, - issues, - pullRequests, - args.bounties ?? [], - args.issueQuality, - ); - - // A synthetic open PR from the local branch metadata — fed to the SAME advisory builder as a real PR. - // Use preflight's normalized linked issues so body references like "Closes #7" match real PR parity. - const syntheticPr: PullRequestRecord = { - repoFullName: input.repoFullName, - number: 0, - title: input.title, - state: "open", - authorLogin: input.contributorLogin, - authorAssociation: input.authorAssociation ?? null, - body: input.body ?? null, - labels: input.labels ?? [], - linkedIssues: preflight.linkedIssues, - }; - - const collisions = buildCollisionReport(input.repoFullName, issues, pullRequests); - const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); - const readiness = buildPublicReadinessScore({ - pr: syntheticPr, - preflight, - queueHealth, - scopedOverlapCount: unionScopedOverlapClusters(collisions, syntheticPr, preflight.collisions).length, - }); - - // Linked-issue finding is surfaced when the repo's public policy treats it as anything but `off`, so the - // gate can evaluate it; evaluateGateCheck decides whether it actually blocks (block) or stays advisory. - // The composite mergeReadiness gate forces the linked-issue sub-gate on (applyMergeReadinessGate), and the - // live path collects linked-issue evidence whenever merge-readiness is enabled (shouldCollectLinkedIssueEvidence, - // queue/processors.ts), so the predictor must surface the finding under mergeReadiness too — otherwise a - // `mergeReadiness:block` repo with linkedIssue unset predicts a false success while the live gate one-shot - // closes the PR on the missing-linked-issue blocker. (#merge-readiness-parity) - const requireLinkedIssue = - (gate.linkedIssue !== null && gate.linkedIssue !== "off") || (gate.mergeReadiness !== null && gate.mergeReadiness !== "off"); - // `duplicateWinnerEnabled` is INTENTIONALLY omitted (#dup-winner): the prospective PR is synthetic #0, but a - // real new PR opened into an existing duplicate cluster gets the HIGHEST number ⇒ it is always a duplicate - // LOSER, never the winner. So the predictor must keep showing the duplicate finding (the honest pre-submit - // answer). Threading the flag here would let isDuplicateClusterWinner(0, …) treat #0 as the winner and - // falsely suppress the block — a false-optimism regression. Do NOT add it without modeling #0 as the loser. - // Thread linked-issue authors from the issues snapshot so the predictor surfaces the self-authored-linked-issue - // finding too — evaluateGateCheck below already receives gate.selfAuthoredLinkedIssue, but without this finding it - // had nothing to act on, so a configured self-authored gate never showed in the preview. Offline path: resolved - // from the snapshot, never a live fetch. (#self-authored-parity) - const issueAuthorByNumber = new Map(issues.filter((issue) => sameRepoFullName(issue.repoFullName, input.repoFullName)).map((issue) => [issue.number, issue.authorLogin ?? null])); - const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null); - // Mirror the live gate (listOtherOpenPullRequests): repo-scoped open siblings only; closed/merged PRs sharing a - // linked issue must not fire duplicate_pr_risk. authorHistory below still needs every state for its grace counts. - const openSiblings = pullRequests.filter( - (otherPr) => - otherPr.state === "open" && - sameRepoFullName(otherPr.repoFullName, input.repoFullName) && - otherPr.number !== syntheticPr.number, - ); - const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: openSiblings, requireLinkedIssue, linkedIssueAuthorLogins }); - - // 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 }), - ); - - // CLA / license-compatibility gate parity (#2564): this metadata-only predictor never resolves a LIVE - // check-run (it runs before the PR exists), so only the phrase-match detection method is predictable — - // checkRunConclusion stays undefined, mirroring evaluateClaCheck's "not evaluated" contract for an - // unresolved check-run. A repo relying solely on checkRunName (no consentPhrase configured) therefore - // predicts no finding either way; the note below discloses this limitation. - if (gate.claMode !== null && gate.claMode !== "off") { - advisory.findings.push(...evaluateClaCheck({ consentPhrase: gate.claConsentPhrase, checkRunName: gate.claCheckRunName }, { body: syntheticPr.body, checkRunConclusion: undefined })); - } - - // 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, - // Parity with the live gate (queue/processors.ts's manifestPolicyGateMode block): the predictor - // already has the same PR body available via input.body, so a manifest_missing_tests prediction must - // not stay stuck at "no validation evidence" when the real gate would already treat the body as evidence. - passedValidationCount: hasValidationNote(input.body ?? "") ? 1 : 0, - }); - const policyCodes = new Set(["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. - const pack: GatePolicyPack = gate.pack ?? "gittensor"; - const effectiveConfirmedContributor = pack === "oss-anti-slop" ? undefined : args.confirmedContributor; - - // Case-insensitive author match so the PREDICTOR agrees with the live gate (which matches case-insensitively). - // First-time grace is retained as compatibility context, but blocker findings are no longer softened by it. - const contributorLoginLc = input.contributorLogin?.toLowerCase(); - const authorHistory = pullRequests.filter((pr) => sameRepoFullName(pr.repoFullName, input.repoFullName) && pr.authorLogin?.toLowerCase() === contributorLoginLc); - - const hardGuardrailGlobs = resolveHardGuardrailGlobs(manifest.settings); - const evaluation = evaluateGateCheck(advisory, { - linkedIssueGateMode: gate.linkedIssue ?? undefined, - duplicatePrGateMode: gate.duplicates ?? undefined, - qualityGateMode: gate.readinessMode ?? undefined, - qualityGateMinScore: gate.readinessMinScore ?? null, - aiReviewGateMode: gate.aiReviewMode ?? undefined, - aiReviewCloseConfidence: gate.aiReviewCloseConfidence ?? null, - 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, - // #2564: only meaningful when the finding was pushed above (gate.claMode opted in); byte-identical otherwise. - claGateMode: gate.claMode ?? undefined, - readinessScore: readiness.total, - confirmedContributor: effectiveConfirmedContributor, - firstTimeContributorGrace: gate.firstTimeContributorGrace ?? undefined, - authorMergedPrCount: authorHistory.filter((pr) => pr.state === "merged" || pr.mergedAt).length, - authorClosedUnmergedPrCount: authorHistory.filter((pr) => pr.state === "closed" && !pr.mergedAt).length, - // Size-hold + guardrail-hold parity (#2458): only meaningful when changed paths were supplied — changedPaths - // is the only size/guardrail input this metadata-only predictor ever receives, so without it neither can be - // evaluated (byte-identical to before). changedLineCount is deliberately left unset: line-diff stats are - // never sent to this predictor, so the size hold can only be predicted from file count (disclosed in the - // note above) — never claim a line count this function has no way to know. - sizeGateMode: gate.sizeMode ?? undefined, - ...(hasChangedPaths - ? { - changedFileCount: changedPaths.length, - guardrailHit: isGuardrailHit(changedPaths, hardGuardrailGlobs), - guardrailMatches: guardrailPathMatches(changedPaths, hardGuardrailGlobs), - } - : {}), - }); - - return { - predicted: true, - basis: "public_config", - pack, - conclusion: evaluation.conclusion, - title: sanitizePublicComment(evaluation.title), - summary: sanitizePublicComment(evaluation.summary), - readinessScore: readiness.total, - confirmedContributor: effectiveConfirmedContributor, - blockers: evaluation.blockers.map(publicSafeFinding), - warnings: evaluation.warnings.map(publicSafeFinding), - funnel: pack === "oss-anti-slop" ? { ...OSS_ANTI_SLOP_FUNNEL } : null, - note: predictedGateNote(hasChangedPaths), - }; -} +export * from "../../packages/gittensory-engine/src/predicted-gate.js"; diff --git a/test/contract/predicted-gate-engine-collision-parity.test.ts b/test/contract/predicted-gate-engine-collision-parity.test.ts new file mode 100644 index 0000000000..d7f7e6dab4 --- /dev/null +++ b/test/contract/predicted-gate-engine-collision-parity.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCollisionReport, + buildPreflightResult, + buildPublicReadinessScore, + buildQueueHealth, + itemSharesPlannedLinkedIssue, +} from "../../packages/gittensory-engine/src/signals/predicted-gate-engine"; +import type { CollisionItem, IssueRecord, PullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../packages/gittensory-engine/src/types/predicted-gate-types"; + +describe("predicted-gate engine collision parity (#2283)", () => { + it("flags possible duplicate work when the planned title overlaps an existing cluster", () => { + const directRepo = repo("owner/direct"); + const issues = [issue(directRepo.fullName, 41, "Login redirect loop on OAuth callback fails")]; + const pullRequests = [pr(directRepo.fullName, 42, "Fix login redirect loop OAuth callback", { authorLogin: "dev", linkedIssues: [] })]; + + const preflight = buildPreflightResult( + { + repoFullName: directRepo.fullName, + title: "Resolve the login redirect loop happening at the OAuth callback", + body: "", + changedFiles: ["src/auth.ts"], + linkedIssues: [], + }, + directRepo, + issues, + pullRequests, + ); + + expect(preflight.findings.map((finding) => finding.code)).toContain("possible_duplicate_work"); + }); + + it("matches duplicate work by a shared linked issue, not a coincident PR number", () => { + const directRepo = repo("owner/direct"); + const sharedIssue = issue(directRepo.fullName, 7, "Token refresh race in the auth middleware"); + const linkingPr = pr(directRepo.fullName, 50, "Guard the token refresh race", { linkedIssues: [7] }); + + const coincidentNumber = buildPreflightResult( + { repoFullName: directRepo.fullName, title: "Add pagination to the labels export endpoint", body: "Fixes #50", changedFiles: ["src/api/labels.ts"], linkedIssues: [50] }, + directRepo, + [sharedIssue], + [linkingPr], + ); + expect(coincidentNumber.findings.map((finding) => finding.code)).not.toContain("possible_duplicate_work"); + + const sharedLinkedIssue = buildPreflightResult( + { repoFullName: directRepo.fullName, title: "Add pagination to the labels export endpoint", body: "Fixes #7", changedFiles: ["src/api/labels.ts"], linkedIssues: [7] }, + directRepo, + [sharedIssue], + [linkingPr], + ); + expect(sharedLinkedIssue.findings.map((finding) => finding.code)).toContain("possible_duplicate_work"); + }); + + it("itemSharesPlannedLinkedIssue intersects linked-issue sets and tolerates missing linkedIssues", () => { + const prItemValue: CollisionItem = { type: "pull_request", number: 42, title: "Unrelated PR", linkedIssues: [9] }; + expect(itemSharesPlannedLinkedIssue(prItemValue, [9])).toBe(true); + expect(itemSharesPlannedLinkedIssue(prItemValue, [42])).toBe(false); + expect(itemSharesPlannedLinkedIssue({ type: "pull_request", number: 9, title: "No links" }, [9])).toBe(false); + }); + + it("does not treat global repo collision clusters as planned-work overlap", () => { + const directRepo = repo("owner/noisy"); + const unrelatedIssues = Array.from({ length: 12 }, (_, index) => issue(directRepo.fullName, index + 1, `Unrelated cache issue ${index + 1}`)); + const unrelatedPullRequests = unrelatedIssues.map((record, index) => + pr(directRepo.fullName, index + 10, `Unrelated cache fix ${index + 1}`, { linkedIssues: [record.number], body: `Fixes #${record.number}` }), + ); + const currentPr = pr(directRepo.fullName, 99, "Isolated docs cleanup", { authorLogin: "dev", linkedIssues: [999], body: "Fixes #999" }); + const collisions = buildCollisionReport(directRepo.fullName, unrelatedIssues, [...unrelatedPullRequests, currentPr]); + const preflight = buildPreflightResult( + { repoFullName: directRepo.fullName, title: currentPr.title, body: currentPr.body ?? undefined, linkedIssues: currentPr.linkedIssues }, + directRepo, + unrelatedIssues, + [...unrelatedPullRequests, currentPr], + ); + + expect(collisions.summary.clusterCount).toBeGreaterThan(0); + expect(preflight.collisions).toHaveLength(0); + }); + + it("drops self-authored path-only overlap between open PRs", () => { + const directRepo = repo("owner/direct"); + const collisions = buildCollisionReport(directRepo.fullName, [], [ + { ...pr(directRepo.fullName, 1, "foo bar", { authorLogin: "alice", linkedIssues: [] }), changedFiles: ["src/services/upload/retry.ts"] }, + { ...pr(directRepo.fullName, 2, "baz qux", { authorLogin: "alice", linkedIssues: [] }), changedFiles: ["src/services/upload/retry.ts"] }, + ]); + expect(collisions.clusters).toHaveLength(0); + }); + + it("buildQueueHealth counts draft PRs and fires inactive_draft_prs finding when stale", () => { + const directRepo = repo("owner/draft-test"); + const collisions = buildCollisionReport(directRepo.fullName, [], []); + const staleDate = new Date(Date.now() - 20 * 86_400_000).toISOString(); + const recentDate = new Date().toISOString(); + + const staleDraftPr = pr(directRepo.fullName, 10, "Draft: refactor auth", { isDraft: true, updatedAt: staleDate }); + const recentDraftPr = pr(directRepo.fullName, 11, "Draft: add pagination", { isDraft: true, updatedAt: recentDate }); + const nonDraftPr = pr(directRepo.fullName, 12, "Fix login redirect", { isDraft: false }); + + const withStaleDraft = buildQueueHealth(directRepo, [], [staleDraftPr, nonDraftPr], collisions); + expect(withStaleDraft.signals.draftPullRequests).toBe(1); + expect(withStaleDraft.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + + const withRecentDraft = buildQueueHealth(directRepo, [], [recentDraftPr, nonDraftPr], collisions); + expect(withRecentDraft.findings.some((f) => f.code === "inactive_draft_prs")).toBe(false); + }); + + it("buildPublicReadinessScore reports queue pressure for stale unlinked queues", () => { + const directRepo = repo("owner/readiness"); + const currentPr = pr(directRepo.fullName, 31, "Maintenance cleanup", { authorLogin: "dev", linkedIssues: [1] }); + const preflight = buildPreflightResult( + { repoFullName: directRepo.fullName, title: currentPr.title, body: currentPr.body ?? undefined, linkedIssues: currentPr.linkedIssues }, + directRepo, + [], + [currentPr], + ); + const staleQueuePullRequests = [44, 45, 46, 47].map((number) => + pr(directRepo.fullName, number, `Stale unlinked queue item ${number}`, { updatedAt: "2020-01-01T00:00:00.000Z" }), + ); + const criticalBurdenQueue = buildQueueHealth( + directRepo, + [], + staleQueuePullRequests, + buildCollisionReport(directRepo.fullName, [], staleQueuePullRequests), + ); + const score = buildPublicReadinessScore({ + pr: currentPr, + preflight: { ...preflight, status: "ready", reviewBurden: "low", findings: [] }, + queueHealth: criticalBurdenQueue, + }); + expect(score.components.find((c) => c.key === "queue_pressure")?.evidence).toContain("4 stale"); + }); +}); + +function repo(fullName: string, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/") as [string, string]; + return { + fullName, + owner, + name, + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: fullName, + emissionShare: 0.02, + issueDiscoveryShare: 0, + labelMultipliers: {}, + maintainerCut: 0, + raw: {}, + ...overrides, + }, + }; +} + +function issue(repoFullName: string, number: number, title: string, overrides: Partial = {}): IssueRecord { + return { + repoFullName, + number, + title, + state: "open", + labels: [], + linkedPrs: [], + ...overrides, + }; +} + +function pr(repoFullName: string, number: number, title: string, overrides: Partial = {}): PullRequestRecord { + return { + repoFullName, + number, + title, + state: "open", + authorLogin: "dev", + labels: [], + linkedIssues: [], + updatedAt: new Date().toISOString(), + ...overrides, + }; +} diff --git a/test/unit/predicted-gate-engine-barrel.test.ts b/test/unit/predicted-gate-engine-barrel.test.ts new file mode 100644 index 0000000000..0c455cf400 --- /dev/null +++ b/test/unit/predicted-gate-engine-barrel.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { buildPredictedGateVerdict, type PredictedGateInput } from "../../packages/gittensory-engine/src/predicted-gate"; + +describe("gittensory-engine predicted-gate barrel exports (#2283)", () => { + it("re-exports predicted-gate symbols from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.buildPredictedGateVerdict).toBe("function"); + expect(typeof buildPredictedGateVerdict).toBe("function"); + const input: PredictedGateInput = { + repoFullName: "acme/widget", + contributorLogin: "dev", + title: "Fix widget", + }; + expect(typeof barrel.buildPredictedGateVerdict).toBe(typeof buildPredictedGateVerdict); + expect(input.repoFullName).toBe("acme/widget"); + }); +}); diff --git a/test/unit/predicted-gate-engine-branch-coverage.test.ts b/test/unit/predicted-gate-engine-branch-coverage.test.ts new file mode 100644 index 0000000000..afad05107e --- /dev/null +++ b/test/unit/predicted-gate-engine-branch-coverage.test.ts @@ -0,0 +1,659 @@ +import { describe, expect, it } from "vitest"; + +import { buildPullRequestAdvisory, evaluateGateCheck, gateAdvisoryInternals } from "../../packages/gittensory-engine/src/advisory/gate-advisory"; +import { evaluateClaCheck, CLA_CHECK_UNRESOLVED_CODE, CLA_CONSENT_MISSING_CODE } from "../../packages/gittensory-engine/src/review/cla-check"; +import { REVIEW_THREAD_BLOCKER_CODE } from "../../packages/gittensory-engine/src/review/review-thread-findings"; +import { guardrailPathMatches } from "../../packages/gittensory-engine/src/signals/change-guardrail"; +import { + buildCollisionReport, + buildLaneAdvice, + buildPreflightResult, + buildPublicReadinessScore, + buildQueueHealth, + classifyBountyLifecycle, + predictedGateEngineInternals, + termOverlap, +} from "../../packages/gittensory-engine/src/signals/predicted-gate-engine"; +import type { IssueQualityReport, PullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../packages/gittensory-engine/src/types/predicted-gate-types"; + +const REPO = repo("acme/widgets"); + +describe("predicted-gate engine branch coverage (#2283)", () => { + it("exercises gate-advisory gateMode and blocker policy branches", () => { + expect(gateAdvisoryInternals.gateMode("off")).toBe("off"); + expect(gateAdvisoryInternals.gateMode("block")).toBe("block"); + expect(gateAdvisoryInternals.gateMode("advisory")).toBe("advisory"); + expect(gateAdvisoryInternals.gateMode(undefined)).toBe("advisory"); + expect(gateAdvisoryInternals.gatePolicyBlocks("advisory", "advisory")).toBe(false); + expect(gateAdvisoryInternals.gatePolicyBlocks("block", "advisory")).toBe(true); + expect(gateAdvisoryInternals.gatePolicyBlocks(undefined, "off")).toBe(false); + expect(gateAdvisoryInternals.buildSizeHoldFinding({})).toBeNull(); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "off", changedFileCount: 99, changedLineCount: 99_999 })).toBeNull(); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "block", changedLineCount: 5000 })?.code).toBe("oversized_pr"); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "block", changedFileCount: 12 })?.code).toBe("oversized_pr"); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "advisory", changedFileCount: 2, changedLineCount: 2 })).toBeNull(); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "advisory", changedFileCount: 2, changedLineCount: 5000 })?.code).toBe("oversized_pr"); + + const finding = (code: string) => ({ code, severity: "warning" as const, title: code, detail: code }); + const advisory = { + linkedIssueGateMode: "advisory" as const, + duplicatePrGateMode: "advisory" as const, + aiReviewGateMode: "advisory" as const, + manifestPolicyGateMode: "advisory" as const, + selfAuthoredLinkedIssueGateMode: "advisory" as const, + lockfileIntegrityGateMode: "off" as const, + claGateMode: "off" as const, + }; + const block = { + linkedIssueGateMode: "block" as const, + duplicatePrGateMode: "block" as const, + aiReviewGateMode: "block" as const, + manifestPolicyGateMode: "block" as const, + selfAuthoredLinkedIssueGateMode: "block" as const, + lockfileIntegrityGateMode: "block" as const, + claGateMode: "block" as const, + }; + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("missing_linked_issue"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("missing_linked_issue"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("duplicate_pr_risk"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("duplicate_pr_risk"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("ai_consensus_defect"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("ai_consensus_defect"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("ai_review_split"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_linked_issue_required"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_linked_issue_required"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_missing_tests"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_missing_tests"), block)).toBe(true); + expect(gateAdvisoryInternals.gatePolicyBlocks("off", "block")).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("self_authored_linked_issue"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("self_authored_linked_issue"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("lockfile_tamper_risk"), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("lockfile_tamper_risk"), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding(CLA_CONSENT_MISSING_CODE), advisory)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding(CLA_CONSENT_MISSING_CODE), block)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding(REVIEW_THREAD_BLOCKER_CODE), advisory)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("secret_leak"), advisory)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("pre_merge_check_required"), advisory)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("unknown_code"), advisory)).toBe(false); + expect(gateAdvisoryInternals.buildSlopGateBlocker({ slopGateMode: "block", slopRisk: 90, slopGateMinScore: 60 })?.code).toBe("slop_risk_above_threshold"); + expect(gateAdvisoryInternals.buildSlopGateBlocker({ slopGateMode: "block", slopRisk: 40, slopGateMinScore: 60 })).toBeNull(); + expect(gateAdvisoryInternals.buildSlopGateBlocker({ slopGateMode: "block", slopRisk: 80 })).not.toBeNull(); + expect(gateAdvisoryInternals.buildQualityGateWarning({ qualityGateMode: "off", readinessScore: 1, qualityGateMinScore: 99 })).toBeNull(); + }); + + it("exercises cla-check and guardrail branch arms", () => { + expect(evaluateClaCheck({ consentPhrase: null, checkRunName: "CLA Bot" }, { checkRunConclusion: "failure" })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(evaluateClaCheck({ consentPhrase: "I agree to the CLA", checkRunName: null }, { body: "no consent here" })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(evaluateClaCheck({ consentPhrase: "I agree to the CLA", checkRunName: null }, { body: undefined })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(evaluateClaCheck({ consentPhrase: "I agree", checkRunName: "CLA Bot" }, { body: "nope", checkRunConclusion: undefined })[0]?.code).toBe( + CLA_CHECK_UNRESOLVED_CODE, + ); + expect(guardrailPathMatches(["src/a.ts"], ["src/a.ts"])).toEqual([{ path: "src/a.ts", glob: "src/a.ts" }]); + expect(guardrailPathMatches(["other.ts"], ["src/a.ts"])).toEqual([]); + const pathological = "src/*-*-*-final.ts"; + expect(guardrailPathMatches(["scripts/x.ts"], [pathological])).toEqual([{ path: "scripts/x.ts", glob: pathological }]); + }); + + it("exercises classifyBountyLifecycle and preflight branch arms", () => { + const issue = { repoFullName: REPO.fullName, number: 1, title: "Issue", state: "open" as const, labels: [], linkedPrs: [] }; + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "cancelled", updatedAt: "2026-01-01T00:00:00.000Z", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, issue)).toBe("cancelled"); + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "completed", updatedAt: "2026-01-01T00:00:00.000Z", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, issue)).toBe("completed"); + expect(classifyBountyLifecycle( + { id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "active funded", discoveredAt: new Date().toISOString(), payload: {} }, + issue, + )).toBe("active"); + expect(classifyBountyLifecycle( + { id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "active funded", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }, + issue, + )).toBe("stale"); + + const ambiguousOnlyBountyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [9], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [{ repoFullName: REPO.fullName, number: 9, title: "Issue", state: "open", labels: [], linkedPrs: [] }], + [], + [{ id: "b3", repoFullName: REPO.fullName, issueNumber: 9, status: "mystery bounty", updatedAt: new Date().toISOString(), discoveredAt: new Date().toISOString(), payload: {} }], + ); + expect(ambiguousOnlyBountyPreflight.findings.some((f) => f.code === "linked_issue_bounty_unverified")).toBe(true); + + const activeBountyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [11], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [{ repoFullName: REPO.fullName, number: 11, title: "Issue", state: "open", labels: [], linkedPrs: [] }], + [], + [{ id: "b4", repoFullName: REPO.fullName, issueNumber: 11, status: "active funded", updatedAt: new Date().toISOString(), discoveredAt: new Date().toISOString(), payload: {} }], + ); + expect(activeBountyPreflight.findings.map((f) => f.code)).not.toContain("linked_issue_bounty_unverified"); + + const ambiguousBountyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "closed", labels: [], linkedPrs: [] }], + [], + [{ id: "b", repoFullName: REPO.fullName, issueNumber: 7, status: "active funded", updatedAt: new Date().toISOString(), discoveredAt: new Date().toISOString(), payload: {} }], + ); + expect(ambiguousBountyPreflight.findings.some((f) => f.code === "linked_issue_bounty_unverified")).toBe(true); + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "active funded", updatedAt: new Date().toISOString(), discoveredAt: new Date().toISOString(), payload: {} }, { ...issue, state: "closed" })).toBe("ambiguous"); + expect( + classifyBountyLifecycle( + { id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "active funded", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }, + issue, + ), + ).toBe("stale"); + + const mediumBurden = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", changedFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"], linkedIssues: [7] }, + REPO, + [], + [], + ); + expect(mediumBurden.reviewBurden).toBe("medium"); + + const ready = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", changedFiles: ["src/a.ts"], linkedIssues: [7], tests: ["src/a.test.ts"] }, + REPO, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [] }], + [], + ); + expect(ready.status).toBe("ready"); + + const staleBountyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [] }], + [], + [{ id: "b", repoFullName: REPO.fullName, issueNumber: 7, status: "active funded", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }], + ); + expect(staleBountyPreflight.findings.some((f) => f.code === "linked_issue_bounty_unverified")).toBe(true); + + const issueQualityNoWarnings: IssueQualityReport = { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: REPO.fullName, summary: "s", contributorGuidance: "s", maintainerGuidance: "s" }, + summary: "s", + issues: [{ number: 8, title: "Issue", status: "needs_proof", score: 40, reasons: [], warnings: [] }], + }; + expect( + buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [8], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [], + [], + [], + issueQualityNoWarnings, + ).findings.some((f) => f.code === "issue_quality_needs_proof"), + ).toBe(true); + + const issueQualityReady: IssueQualityReport = { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: REPO.fullName, summary: "s", contributorGuidance: "s", maintainerGuidance: "s" }, + summary: "s", + issues: [{ number: 7, title: "Issue", status: "ready", score: 100, reasons: [], warnings: [] }], + }; + expect( + buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [], + [], + [], + issueQualityReady, + ).findings.map((f) => f.code), + ).not.toContain("issue_quality_do_not_use"); + + const linkedIssueCollision = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Unrelated title", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [], + [ + { ...pr(REPO.fullName, 20, "Other work"), linkedIssues: [7] }, + { ...pr(REPO.fullName, 21, "More work"), linkedIssues: [7] }, + ], + ); + expect(linkedIssueCollision.collisions.length).toBeGreaterThan(0); + + const titleOverlapCollision = buildPreflightResult( + { + repoFullName: REPO.fullName, + title: "Resolve login redirect loop OAuth callback handler", + body: "", + linkedIssues: [], + changedFiles: ["src/auth.ts"], + tests: ["src/auth.test.ts"], + }, + REPO, + [{ repoFullName: REPO.fullName, number: 51, title: "Login redirect loop OAuth cleanup", state: "open", labels: [], linkedPrs: [] }], + [{ ...pr(REPO.fullName, 52, "Login redirect loop OAuth middleware"), changedFiles: ["src/auth.ts"] }], + ); + expect(titleOverlapCollision.collisions.length).toBeGreaterThan(0); + }); + + it("exercises collision pairwise branch arms", () => { + expect( + buildCollisionReport(REPO.fullName, [], [ + { ...pr(REPO.fullName, 1, "alpha upload retry client"), authorLogin: "alice", changedFiles: ["package-lock.json"] }, + { ...pr(REPO.fullName, 2, "beta upload retry service"), authorLogin: "bob", changedFiles: ["package-lock.json"] }, + ]).clusters, + ).toHaveLength(0); + + const highOverlap = buildCollisionReport(REPO.fullName, [], [ + { ...pr(REPO.fullName, 3, "upload retry client handler service"), authorLogin: "alice", changedFiles: ["src/core/upload.ts"] }, + { ...pr(REPO.fullName, 4, "upload retry service handler client"), authorLogin: "bob", changedFiles: ["src/core/upload.ts"] }, + ]); + expect(highOverlap.clusters.some((c) => c.risk === "high")).toBe(true); + + const sharedIssuePair = buildCollisionReport( + REPO.fullName, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [], authorLogin: "r" }], + [ + { ...pr(REPO.fullName, 5, "A"), linkedIssues: [7] }, + { ...pr(REPO.fullName, 6, "B"), linkedIssues: [7] }, + ], + ); + expect(sharedIssuePair.clusters.length).toBeGreaterThan(0); + + const pairwiseSharedIssue = buildCollisionReport(REPO.fullName, [], [ + { ...pr(REPO.fullName, 8, "First"), linkedIssues: [42] }, + { ...pr(REPO.fullName, 9, "Second"), linkedIssues: [42] }, + ]); + expect(pairwiseSharedIssue.clusters.some((c) => c.reason.includes("same linked issue"))).toBe(true); + + const recentMergedSharedIssue = buildCollisionReport( + REPO.fullName, + [], + [{ ...pr(REPO.fullName, 10, "Open overlap"), linkedIssues: [55], changedFiles: ["src/auth.ts"] }], + [{ repoFullName: REPO.fullName, number: 88, title: "Merged overlap", authorLogin: "bob", labels: [], linkedIssues: [55], changedFiles: ["src/auth.ts"] }], + ); + expect(recentMergedSharedIssue.clusters.some((c) => c.risk === "medium")).toBe(true); + + const recentMergedNoLinks = buildCollisionReport( + REPO.fullName, + [], + [{ ...pr(REPO.fullName, 13, "upload retry client handler"), authorLogin: "alice", changedFiles: ["src/core/upload.ts"] }], + [{ repoFullName: REPO.fullName, number: 90, title: "upload retry service handler", authorLogin: "bob", labels: [], linkedIssues: [], changedFiles: ["src/core/upload.ts"] }], + ); + expect(recentMergedNoLinks.clusters.length).toBeGreaterThan(0); + + const selfAuthoredPathOverlap = buildCollisionReport(REPO.fullName, [], [ + { ...pr(REPO.fullName, 14, "qwerty alpha"), authorLogin: "alice", changedFiles: ["src/services/upload/retry.ts"] }, + { ...pr(REPO.fullName, 15, "asdf beta"), authorLogin: "alice", changedFiles: ["src/services/upload/retry.ts"] }, + ]); + const differentLinkedIssues = buildCollisionReport(REPO.fullName, [], [ + { ...pr(REPO.fullName, 16, "upload retry client handler"), authorLogin: "alice", linkedIssues: [1], changedFiles: ["src/core/upload.ts"] }, + { ...pr(REPO.fullName, 17, "upload retry service handler"), authorLogin: "bob", linkedIssues: [2], changedFiles: ["src/core/upload.ts"] }, + ]); + expect(differentLinkedIssues.clusters.length).toBeGreaterThan(0); + }); + + it("exercises readiness and queue-pressure component branches", () => { + const internals = predictedGateEngineInternals; + expect(internals.reviewLoadComponentScore("low")).toBe(20); + expect(internals.reviewLoadComponentScore("medium")).toBe(14); + expect(internals.reviewLoadComponentScore("high")).toBe(8); + expect(internals.changeScopeEvidence({ ...pr(REPO.fullName, 1, "Fix"), labels: ["size:L"], isDraft: true, linkedIssues: [7] }, "high")).toContain("size label"); + expect(internals.changeScopeEvidence({ ...pr(REPO.fullName, 2, "Fix"), labels: [], linkedIssues: [] }, "low")).toContain("no linked issue"); + + const holdPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: [] }, + { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 0 } }, + [], + [], + [], + null, + false, + ); + expect(internals.validationComponent({ ...pr(REPO.fullName, 3, "Fix"), body: "npm test passed" }, holdPreflight).score).toBe(5); + expect(internals.validationComponent({ ...pr(REPO.fullName, 4, "Fix"), body: "npm test passed" }, { ...holdPreflight, status: "needs_work", findings: [{ code: "missing_test_evidence", severity: "warning", title: "t", detail: "d" }] }).score).toBe(12); + expect( + internals.validationComponent({ ...pr(REPO.fullName, 31, "Fix"), body: "no validation note" }, { + ...holdPreflight, + status: "needs_work", + findings: [{ code: "missing_test_evidence", severity: "warning", title: "t", detail: "d" }], + }).score, + ).toBe(10); + + const emptyQueue = internals.queuePressureComponent({ + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + burdenScore: 0, + level: "low", + summary: "s", + signals: { openIssues: 0, openPullRequests: 0, unlinkedPullRequests: 0, stalePullRequests: 0, draftPullRequests: 0, maintainerAuthoredPullRequests: 0, collisionClusters: 0, ageBuckets: { under7Days: 0, days7To30: 0, over30Days: 0 }, likelyReviewablePullRequests: 0, cachedOpenPullRequests: 0, likelyReviewablePullRequestsSource: "cache" }, + findings: [], + }); + expect(emptyQueue.evidence).toContain("0 likely reviewable"); + + const sampledQueue = buildQueueHealth(REPO, [], [{ ...pr(REPO.fullName, 8, "Open"), linkedIssues: [7] }], buildCollisionReport(REPO.fullName, [], []), { openPullRequests: 40 }); + expect(internals.queuePressureComponent(sampledQueue).evidence).toContain("sampled"); + + expect( + internals.queuePressureComponent({ + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + burdenScore: 0, + level: "low", + summary: "s", + signals: { + openIssues: 0, + openPullRequests: 12, + unlinkedPullRequests: 0, + stalePullRequests: 0, + draftPullRequests: 0, + maintainerAuthoredPullRequests: 0, + collisionClusters: 0, + ageBuckets: { under7Days: 2, days7To30: 1, over30Days: 0 }, + likelyReviewablePullRequests: 2, + likelyReviewablePullRequestsSource: undefined, + }, + findings: [], + }).evidence, + ).toContain("sampled"); + + expect( + internals.queuePressureComponent({ + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + burdenScore: 0, + level: "low", + summary: "s", + signals: { + openIssues: 0, + openPullRequests: 5, + unlinkedPullRequests: 0, + stalePullRequests: 0, + draftPullRequests: 0, + maintainerAuthoredPullRequests: 0, + collisionClusters: 0, + ageBuckets: { under7Days: 0, days7To30: 0, over30Days: 0 }, + likelyReviewablePullRequests: 0, + likelyReviewablePullRequestsSource: "sampled_cache", + }, + findings: [], + }).evidence, + ).toContain("unavailable"); + + expect(internals.queuePressureOpenPullRequestScore(0)).toBe(10); + expect(internals.queuePressureOpenPullRequestScore(6)).toBe(8); + expect(internals.queuePressureOpenPullRequestScore(10)).toBe(5); + expect(internals.queuePressureOpenPullRequestScore(20)).toBe(3); + + expect(internals.extractLinkedIssueNumbers("closes other/repo#9", REPO.fullName)).not.toContain(9); + expect(internals.extractLinkedIssueNumbers(`closes ${REPO.fullName}#9`, REPO.fullName)).toContain(9); + + const issueQuality: IssueQualityReport = { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: REPO.fullName, summary: "s", contributorGuidance: "s", maintainerGuidance: "s" }, + summary: "s", + issues: [{ number: 7, title: "Issue", status: "ready", score: 100, reasons: [], warnings: [] }], + }; + const overlapPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Resolve login redirect loop OAuth callback handler", body: "", changedFiles: ["src/auth.ts"], linkedIssues: [] }, + REPO, + [{ repoFullName: REPO.fullName, number: 51, title: "Login redirect loop OAuth cleanup", state: "open", labels: [], linkedPrs: [] }], + [{ ...pr(REPO.fullName, 52, "Login redirect loop OAuth middleware"), changedFiles: ["src/auth.ts"] }], + ); + const readiness = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 9, "Fix"), body: "Validation: npm test", labels: ["size:large"], isDraft: true, linkedIssues: [7] }, + preflight: { ...overlapPreflight, status: "ready", reviewBurden: "medium", findings: [] }, + queueHealth: buildQueueHealth(REPO, [], [{ ...pr(REPO.fullName, 10, "Stale"), linkedIssues: [], updatedAt: "2000-01-01T00:00:00.000Z" }], buildCollisionReport(REPO.fullName, [], []), { openPullRequests: 15, likelyReviewablePullRequests: 3 }), + }); + expect(readiness.total).toBeGreaterThan(0); + expect(buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, REPO, [], [], [], issueQuality).findings.map((f) => f.code)).not.toContain("issue_quality_do_not_use"); + + const staleDraftOnlyCreatedAt = buildQueueHealth( + REPO, + [], + [{ ...pr(REPO.fullName, 77, "Draft only createdAt"), isDraft: true, updatedAt: undefined, createdAt: "2000-01-01T00:00:00.000Z" }], + buildCollisionReport(REPO.fullName, [], []), + ); + expect(staleDraftOnlyCreatedAt.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + + const lowBurdenQueue = buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])); + expect(lowBurdenQueue.level).toBe("low"); + const mediumQueue = buildQueueHealth( + REPO, + [], + [1, 2, 3].map((number) => pr(REPO.fullName, number, `Unlinked ${number}`, { linkedIssues: [] })), + buildCollisionReport(REPO.fullName, [], []), + ); + expect(mediumQueue.level).toBe("medium"); + const highQueue = buildQueueHealth( + REPO, + [], + [1, 2, 3, 4].map((number) => pr(REPO.fullName, number, `Unlinked ${number}`, { linkedIssues: [] })), + buildCollisionReport(REPO.fullName, [], []), + ); + expect(highQueue.level).toBe("high"); + const criticalStale = [44, 45, 46, 47].map((number) => + pr(REPO.fullName, number, `Stale ${number}`, { linkedIssues: [], updatedAt: "2000-01-01T00:00:00.000Z" }), + ); + expect(buildQueueHealth(REPO, [], criticalStale, buildCollisionReport(REPO.fullName, [], criticalStale)).level).toBe("critical"); + + const bodyLinkedIssues = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: `closes ${REPO.fullName}#12 and fixes #8`, changedFiles: ["src/a.ts"] }, + REPO, + [], + [], + ); + expect(bodyLinkedIssues.linkedIssues).toEqual([8, 12]); + const mergedLinkedIssues = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "closes #5", linkedIssues: [3], changedFiles: ["src/a.ts"] }, + REPO, + [], + [], + ); + expect(mergedLinkedIssues.linkedIssues).toEqual([3, 5]); + }); + + it("exercises codecov-critical helper and gate-evaluation branches", () => { + const finding = (code: string) => ({ code, severity: "warning" as const, title: code, detail: code }); + const advisoryBase = { + id: "a", + targetType: "pull_request" as const, + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "success" as const, + severity: "info" as const, + title: "t", + summary: "s", + generatedAt: "2026-01-01T00:00:00.000Z", + findings: [] as Array<{ code: string; severity: "warning" | "critical"; title: string; detail: string; action?: string }>, + }; + const policyBlockers = evaluateGateCheck( + { + ...advisoryBase, + findings: [ + finding(REVIEW_THREAD_BLOCKER_CODE), + { code: "secret_leak", severity: "critical", title: "secret", detail: "secret", action: "rotate" }, + finding("ai_review_split"), + ], + }, + { aiReviewGateMode: "block" }, + ); + expect(policyBlockers.blockers.map((b) => b.code)).toEqual( + expect.arrayContaining([REVIEW_THREAD_BLOCKER_CODE, "secret_leak", "ai_review_split"]), + ); + + expect(termOverlap({ terms: new Set(), size: 0 }, { terms: new Set(["alpha"]), size: 1 }).score).toBe(0); + expect(termOverlap({ terms: new Set(["alpha"]), size: 1 }, { terms: new Set(), size: 0 }).score).toBe(0); + expect(predictedGateEngineInternals.truncateText("short", 10)).toBe("short"); + expect(predictedGateEngineInternals.truncateText("x".repeat(20), 10)).toHaveLength(10); + expect(predictedGateEngineInternals.sharesMeaningfulFile([], ["src/a.ts"])).toBe(false); + expect(predictedGateEngineInternals.sharesMeaningfulFile(["src/a.ts"], [])).toBe(false); + + const issue = { repoFullName: REPO.fullName, number: 1, title: "Issue", state: "open" as const, labels: [], linkedPrs: [] }; + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: " ", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, issue)).toBe("unknown"); + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "archived bounty", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, issue)).toBe("historical"); + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: "mystery", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, issue)).toBe("ambiguous"); + + const selfAuthored = buildPullRequestAdvisory( + REPO, + { + ...pr(REPO.fullName, 1, "Fix"), + authorLogin: "alice", + linkedIssues: [7], + }, + { linkedIssueAuthorLogins: ["alice"] }, + ); + expect(selfAuthored.findings.some((f) => f.code === "self_authored_linked_issue")).toBe(true); + + const readiness = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 2, "Fix"), body: "No issue because docs-only typo", linkedIssues: [] }, + preflight: buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "No issue because docs-only typo", linkedIssues: [], changedFiles: ["README.md"] }, REPO, [], []), + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + }); + expect(readiness.components.find((c) => c.key === "traceability")?.evidence).toContain("no-issue rationale"); + + const withIdentifiers = gateAdvisoryInternals.advisory( + "pull_request", + "acme/widgets#1", + REPO.fullName, + [], + "summary", + 1, + 7, + "sha123", + ); + expect(withIdentifiers.pullNumber).toBe(1); + expect(withIdentifiers.issueNumber).toBe(7); + expect(withIdentifiers.headSha).toBe("sha123"); + + const overflowGuardrail = gateAdvisoryInternals.buildGuardrailHoldFinding( + Array.from({ length: 6 }, (_, index) => ({ path: `src/file-${index}.ts`, glob: "src/**" })), + ); + expect(overflowGuardrail.detail).toContain("and 1 more"); + + const issueDiscoveryLane = buildLaneAdvice( + { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 1, issueDiscoveryShare: 1 } }, + REPO.fullName, + ); + expect(issueDiscoveryLane.lane).toBe("issue_discovery"); + + const openReady = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 3, "Fix"), state: "open", isDraft: false, linkedIssues: [7] }, + preflight: buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"] }, REPO, [], []), + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + }); + expect(openReady.components.find((c) => c.key === "pr_state")?.score).toBe(10); + + const openDraft = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 4, "Fix"), state: "open", isDraft: true, linkedIssues: [7, 8] }, + preflight: buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7, 8], changedFiles: ["src/a.ts"] }, REPO, [], []), + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + }); + expect(openDraft.components.find((c) => c.key === "pr_state")?.score).toBe(6); + expect(openDraft.components.find((c) => c.key === "change_scope")?.evidence).toContain("2 linked issues"); + + const closedPr = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 5, "Fix"), state: "closed", isDraft: false, linkedIssues: [7] }, + preflight: buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"] }, REPO, [], []), + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + }); + expect(closedPr.components.find((c) => c.key === "pr_state")?.score).toBe(3); + expect(closedPr.components.find((c) => c.key === "change_scope")?.evidence).toContain("1 linked issue"); + }); + + it("covers lane_not_recommended maintainer branches and scoped overlap pluralization", () => { + const inactiveRepo = { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 0 } }; + const maintainerLane = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "Closes #7", linkedIssues: [7], authorAssociation: "OWNER" }, + inactiveRepo, + [], + [], + ); + const contributorLane = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "Closes #7", linkedIssues: [7], authorAssociation: "CONTRIBUTOR" }, + inactiveRepo, + [], + [], + ); + expect(maintainerLane.findings.find((finding) => finding.code === "lane_not_recommended")).toMatchObject({ + severity: "info", + title: "Repo lane unavailable for contributor scoring", + detail: expect.stringContaining("Maintainer-authored work is treated as repo stewardship"), + action: "No action.", + }); + expect(contributorLane.findings.find((finding) => finding.code === "lane_not_recommended")).toMatchObject({ + severity: "warning", + title: "Repo lane is not ready for a confident recommendation", + action: "Refresh registry data or choose a registered active repo.", + }); + + const missingRepo = { ...REPO, isRegistered: false, registryConfig: null }; + const ownerUnknownLane = buildPreflightResult( + { repoFullName: missingRepo.fullName, title: "Fix", body: "Closes #7", linkedIssues: [7], authorAssociation: "OWNER" }, + missingRepo, + [], + [], + [], + null, + true, + ); + const outsideUnknownLane = buildPreflightResult( + { repoFullName: missingRepo.fullName, title: "Fix", body: "Closes #7", linkedIssues: [7], authorAssociation: "CONTRIBUTOR" }, + missingRepo, + [], + [], + [], + null, + true, + ); + expect(ownerUnknownLane.findings.find((finding) => finding.code === "lane_not_recommended")?.severity).toBe("info"); + expect(outsideUnknownLane.findings.find((finding) => finding.code === "lane_not_recommended")?.severity).toBe("warning"); + + const preflight = buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"] }, REPO, [], []); + const singularOverlap = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 6, "Fix"), linkedIssues: [7] }, + preflight, + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + scopedOverlapCount: 1, + linkedDuplicatePrs: [], + }); + const pluralOverlap = buildPublicReadinessScore({ + pr: { ...pr(REPO.fullName, 7, "Fix"), linkedIssues: [7] }, + preflight, + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + scopedOverlapCount: 2, + linkedDuplicatePrs: [], + }); + expect(singularOverlap.components.find((component) => component.key === "related_work")?.evidence).toBe("1 scoped overlap found."); + expect(pluralOverlap.components.find((component) => component.key === "related_work")?.evidence).toBe("2 scoped overlaps found."); + }); +}); + +function repo(fullName: string, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/") as [string, string]; + return { + fullName, + owner, + name, + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: fullName, + emissionShare: 1, + issueDiscoveryShare: 0, + labelMultipliers: {}, + maintainerCut: 0, + raw: {}, + ...overrides, + }, + }; +} + +function pr(repoFullName: string, number: number, title: string, overrides: Partial = {}): PullRequestRecord { + return { + repoFullName, + number, + title, + state: "open", + authorLogin: "dev", + labels: [], + linkedIssues: [], + updatedAt: new Date().toISOString(), + ...overrides, + }; +} diff --git a/test/unit/predicted-gate-engine-coverage.test.ts b/test/unit/predicted-gate-engine-coverage.test.ts new file mode 100644 index 0000000000..0e42644720 --- /dev/null +++ b/test/unit/predicted-gate-engine-coverage.test.ts @@ -0,0 +1,1253 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateGateCheck, buildPullRequestAdvisory, gateAdvisoryInternals } from "../../packages/gittensory-engine/src/advisory/gate-advisory"; +import { buildFocusManifestGuidance, isFocusManifestPublicSafe, matchesManifestPath } from "../../packages/gittensory-engine/src/focus-manifest/guidance"; +import { sanitizePublicComment } from "../../packages/gittensory-engine/src/github/sanitize-public-comment"; +import { + CLA_CHECK_UNRESOLVED_CODE, + CLA_CONSENT_MISSING_CODE, + evaluateClaCheck, + type ClaCheckConfig, +} from "../../packages/gittensory-engine/src/review/cla-check"; +import { evaluatePreMergeChecks, PRE_MERGE_CHECK_ADVISORY_CODE, PRE_MERGE_CHECK_BLOCKING_CODE, PRE_MERGE_CHECK_UNRESOLVED_CODE } from "../../packages/gittensory-engine/src/review/pre-merge-checks"; +import { REVIEW_THREAD_BLOCKER_CODE } from "../../packages/gittensory-engine/src/review/review-thread-findings"; +import { diffFilePriority } from "../../packages/gittensory-engine/src/review/diff-file-priority"; +import { + clearLabelPatternRegExpCacheForTest, + LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES, + labelMatchesPattern, + labelPatternRegExpCacheKeysForTest, +} from "../../packages/gittensory-engine/src/scoring/label-match"; +import { + changedPathsHittingGuardrail, + globToRegExp, + guardrailPathMatches, + isGuardrailHit, + matchesAny, +} from "../../packages/gittensory-engine/src/signals/change-guardrail"; +import { isDuplicateClusterWinner, isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "../../packages/gittensory-engine/src/signals/duplicate-winner"; +import { buildCollisionReport, buildPreflightResult, buildPublicReadinessScore, buildQueueHealth, classifyBountyLifecycle, itemSharesPlannedLinkedIssue, predictedGateEngineInternals, termOverlap, unionScopedOverlapClusters } from "../../packages/gittensory-engine/src/signals/predicted-gate-engine"; +import type { CollisionItem, FocusManifest, IssueQualityReport, PreMergeCheck, PullRequestRecord, RepositoryRecord } from "../../packages/gittensory-engine/src/types/predicted-gate-types"; + +const REPO: RepositoryRecord = { + fullName: "acme/widgets", + owner: "acme", + name: "widgets", + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "acme/widgets", + emissionShare: 1, + issueDiscoveryShare: 0, + labelMultipliers: { "type:*": 1.2, bug: 1.1 }, + maintainerCut: 0, + raw: {}, + }, +}; + +const PR: PullRequestRecord = { + repoFullName: "acme/widgets", + number: 9, + title: "Fix upload retries", + state: "open", + authorLogin: "miner1", + labels: ["type:bug-fix", "bug"], + linkedIssues: [7], +}; + +const claConfig = (over: Partial = {}): ClaCheckConfig => ({ + consentPhrase: null, + checkRunName: null, + ...over, +}); + +const preMergeCheck = (over: Partial = {}): PreMergeCheck => ({ + name: "Check", + whenPaths: [], + titleContains: null, + descriptionContains: null, + requireLabel: null, + enforce: false, + ...over, +}); + +describe("predicted-gate engine module coverage (#2283)", () => { + it("mirrors scoring label matcher semantics through the engine copy", () => { + expect(labelMatchesPattern("type:bug-fix", "type:*")).toBe(true); + expect(labelMatchesPattern("kind:chore", "type:*")).toBe(false); + expect(labelMatchesPattern("Priority:1", "priority:?")).toBe(true); + expect(labelMatchesPattern("priority:10", "priority:?")).toBe(false); + expect(labelMatchesPattern("kind/bug", "kind/[bc]ug")).toBe(true); + expect(labelMatchesPattern("kind/dug", "kind/[!bc]ug")).toBe(true); + expect(labelMatchesPattern("^ug", "[^x]ug")).toBe(true); + expect(labelMatchesPattern("bug", "[^x]ug")).toBe(false); + expect(labelMatchesPattern("x", "[z-a]")).toBe(false); + expect(labelMatchesPattern("[bug", "[bug")).toBe(true); + expect(labelMatchesPattern("m", "[a-z-9]")).toBe(true); + expect(labelMatchesPattern("5", "[!a-z-9]")).toBe(true); + expect(labelMatchesPattern("type-bug-fix", "type-*-*")).toBe(true); + expect(labelMatchesPattern("a-b-c-final", "*-*-*-final")).toBe(false); + expect(labelMatchesPattern("x", "[!]")).toBe(false); + expect(labelMatchesPattern("a.b", "a.b")).toBe(true); + }); + + it("bounds the memoized label pattern cache and evicts least-recently-used entries", () => { + clearLabelPatternRegExpCacheForTest(); + for (let i = 0; i < LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES; i += 1) { + expect(labelMatchesPattern(`kind:${i}`, `kind:${i}`)).toBe(true); + } + expect(labelPatternRegExpCacheKeysForTest()).toHaveLength(LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES); + expect(labelMatchesPattern("kind:0", "kind:0")).toBe(true); + expect(labelMatchesPattern("kind:overflow", "kind:overflow")).toBe(true); + expect(labelPatternRegExpCacheKeysForTest()).toContain("kind:0"); + expect(labelPatternRegExpCacheKeysForTest()).not.toContain("kind:1"); + clearLabelPatternRegExpCacheForTest(); + }); + + it("exercises duplicate-winner election helpers", () => { + expect(isDuplicateClusterWinnerByClaim({ number: 1, createdAt: "2026-01-01T00:00:00.000Z" }, [{ number: 2, createdAt: "2026-01-02T00:00:00.000Z" }])).toBe(true); + expect( + isDuplicateClusterWinnerByClaim( + { number: 2, linkedIssueClaimedAt: "2026-01-02T00:00:00.000Z" }, + [{ number: 1, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }], + ), + ).toBe(false); + expect( + isDuplicateClusterWinnerByClaim( + { number: 3, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }, + [{ number: 2, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }], + ), + ).toBe(false); + expect( + isDuplicateClusterWinnerByClaim( + { number: 1, createdAt: "2026-01-01T00:00:00.000Z" }, + [{ number: 2, createdAt: "2026-01-01T00:00:00.000Z" }], + ), + ).toBe(true); + expect(resolveDuplicateClusterWinnerNumber({ number: 2, createdAt: "2026-01-02T00:00:00.000Z" }, [{ number: 1, createdAt: "2026-01-01T00:00:00.000Z" }])).toBe(1); + expect(resolveDuplicateClusterWinnerNumber({ number: 1, createdAt: null }, [{ number: 2, createdAt: null }])).toBeNull(); + }); + + it("exercises diff-file priority tiers and guardrail glob helpers", () => { + expect(diffFilePriority("src/app.ts")).toBe(0); + expect(diffFilePriority("src/app.test.ts")).toBe(1); + expect(diffFilePriority("README.md")).toBe(2); + expect(diffFilePriority("package-lock.json")).toBe(4); + expect(diffFilePriority("dist/bundle.js")).toBe(4); + expect(globToRegExp("src/**/model.ts").test("src/a/deep/model.ts")).toBe(true); + expect(globToRegExp("public/**/*.json").test("public/release/config.json")).toBe(true); + expect(matchesAny("completely/unrelated.md", ["*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*/*"])).toBe(true); + expect(changedPathsHittingGuardrail(["src/a.ts"], [])).toEqual([]); + expect(isGuardrailHit(["docs/readme.md"], ["scripts/**"])).toBe(false); + expect(matchesManifestPath("", "src/**")).toBe(false); + expect(matchesManifestPath("src/a.ts", "")).toBe(false); + expect(matchesManifestPath("src/nested/a.ts", "src/")).toBe(true); + expect(isFocusManifestPublicSafe("wallet hotkey farming")).toBe(false); + expect(isFocusManifestPublicSafe("Keep changes focused.")).toBe(true); + }); + + it("exercises guardrail path matching", () => { + expect(isGuardrailHit([".github/workflows/ci.yml"], [".github/workflows/*"])).toBe(true); + expect(guardrailPathMatches([".github/workflows/ci.yml"], [".github/workflows/*"])).toEqual([ + { path: ".github/workflows/ci.yml", glob: ".github/workflows/*" }, + ]); + }); + + it("exercises sanitizePublicComment redaction paths", () => { + expect(sanitizePublicComment("score estimate 12.5 -> 41.2")).toContain("private context"); + expect(sanitizePublicComment("reviewability internals")).toContain("private context"); + expect(sanitizePublicComment("@gittensory reviewability score")).toContain("reviewability"); + expect(sanitizePublicComment("likely_duplicate overlap")).toContain("possible overlap"); + expect(sanitizePublicComment("open pr count 12 exceeds threshold 10")).toContain("private context"); + }); + + it("exercises focus-manifest guidance branches", () => { + const manifest: FocusManifest = { + present: true, + source: "repo_file", + wantedPaths: ["src/"], + preferredLabels: ["bug"], + linkedIssuePolicy: "required", + testExpectations: ["npm test"], + issueDiscoveryPolicy: "discouraged", + maintainerNotes: [], + publicNotes: ["Keep changes focused."], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }; + const offFocus = buildFocusManifestGuidance({ manifest, changedPaths: ["docs/readme.md"], labels: [], linkedIssueCount: 0, testFileCount: 0 }); + expect(offFocus.findings.some((f) => f.code === "manifest_off_focus")).toBe(true); + expect(offFocus.findings.some((f) => f.code === "manifest_linked_issue_required")).toBe(true); + expect(offFocus.findings.some((f) => f.code === "manifest_issue_discovery_discouraged")).toBe(true); + const aligned = buildFocusManifestGuidance({ manifest, changedPaths: ["src/a.ts"], labels: ["bug"], linkedIssueCount: 1, testFileCount: 1 }); + expect(aligned.findings.some((f) => f.code === "manifest_preferred_path")).toBe(true); + }); + + it("exercises pre-merge unresolved path-gated checks", () => { + const findings = evaluatePreMergeChecks( + [{ name: "migrations", whenPaths: ["migrations/**"], titleContains: null, descriptionContains: null, requireLabel: null, enforce: true }], + { title: "x", body: "y", labels: [], changedPaths: [], filesResolved: false }, + ); + expect(findings[0]?.code).toBe(PRE_MERGE_CHECK_UNRESOLVED_CODE); + }); + + it("exercises preflight bounty and issue-quality branches", () => { + const issueQuality: IssueQualityReport = { + repoFullName: "acme/widgets", + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: "acme/widgets", summary: "ok", contributorGuidance: "ok", maintainerGuidance: "ok" }, + issues: [{ number: 7, title: "Issue", status: "do_not_use", score: 0, reasons: [], warnings: ["already solved"] }], + summary: "hold", + }; + const preflight = buildPreflightResult( + { repoFullName: "acme/widgets", title: "Fix", body: "Closes #7", linkedIssues: [7], changedFiles: ["src/a.ts"] }, + REPO, + [], + [], + [{ id: "b1", repoFullName: "acme/widgets", issueNumber: 7, status: "completed", payload: {} }], + issueQuality, + ); + expect(preflight.findings.some((f) => f.code === "issue_quality_do_not_use")).toBe(true); + expect(preflight.findings.some((f) => f.code === "linked_issue_bounty_historical")).toBe(true); + }); + + it("exercises advisory label context and dry-run displayConclusion", () => { + const advisory = buildPullRequestAdvisory(REPO, PR); + expect(advisory.findings.some((f) => f.code === "label_context_found")).toBe(true); + const dry = evaluateGateCheck(advisory, { dryRun: true, duplicatePrGateMode: "advisory", linkedIssueGateMode: "advisory", aiReviewGateMode: "advisory" }); + expect(dry.displayConclusion).toBeDefined(); + }); + + it("exercises advisory edge cases and gate failures", () => { + const missingRepo = buildPullRequestAdvisory(null, PR); + expect(missingRepo.findings.some((f) => f.code === "repo_not_registered")).toBe(true); + const missingPr = buildPullRequestAdvisory(REPO, null); + expect(missingPr.findings.some((f) => f.code === "pr_not_cached")).toBe(true); + const blocked = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "duplicate_pr_risk", severity: "warning", title: "dup", detail: "dup" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { duplicatePrGateMode: "block" }, + ); + expect(blocked.conclusion).toBe("failure"); + }); + + it("exercises deprecated duplicate winner helper and lane advice branches", () => { + expect(isDuplicateClusterWinner(1, [2, 3])).toBe(true); + expect(isDuplicateClusterWinner(3, [1, 2])).toBe(false); + const inactive = buildPreflightResult( + { repoFullName: "acme/widgets", title: "Fix", body: "Closes #7", linkedIssues: [7] }, + { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 0 } }, + [], + [], + ); + expect(inactive.lane.lane).toBe("inactive"); + }); + + it("exercises manifest globstar path matching", () => { + const manifest: FocusManifest = { + present: true, + source: "repo_file", + wantedPaths: ["**/safe.ts"], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }; + const guidance = buildFocusManifestGuidance({ manifest, changedPaths: ["safe.ts", "nested/safe.ts"], linkedIssueCount: 1, testFileCount: 1 }); + expect(guidance.matchedWantedPaths.length).toBeGreaterThan(0); + }); + + it("exercises lane, collision, queue, and preflight edge branches", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...REPO, + registryConfig: { ...REPO.registryConfig!, issueDiscoveryShare: 1, emissionShare: 1 }, + }; + const splitRepo: RepositoryRecord = { + ...REPO, + registryConfig: { ...REPO.registryConfig!, issueDiscoveryShare: 0.5, emissionShare: 1 }, + }; + const discoveryPreflight = buildPreflightResult({ repoFullName: REPO.fullName, title: "Report issue", body: "", linkedIssues: [] }, issueDiscoveryRepo, [], []); + expect(discoveryPreflight.lane.lane).toBe("issue_discovery"); + const splitPreflight = buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "Closes #7", linkedIssues: [7] }, splitRepo, [], []); + expect(splitPreflight.lane.lane).toBe("split"); + + const collisions = buildCollisionReport( + REPO.fullName, + [], + [ + { ...PR, number: 1, authorLogin: "alice", title: "retry upload client", changedFiles: ["src/upload.ts"] }, + { ...PR, number: 2, authorLogin: "alice", title: "retry upload service", changedFiles: ["src/upload.ts"] }, + { ...PR, number: 3, authorLogin: "bob", title: "totally different", changedFiles: ["src/upload.ts"] }, + { ...PR, number: 4, authorLogin: "carol", title: "totally different too", changedFiles: ["src/upload.ts"] }, + ], + ); + expect(collisions.clusters.length).toBeGreaterThan(0); + + const queue = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Big change", body: "", linkedIssues: [7, 8], changedFiles: Array.from({ length: 12 }, (_, i) => `src/f${i}.ts`) }, + REPO, + [], + [ + { ...PR, number: 11, linkedIssues: [], updatedAt: "2020-01-01T00:00:00.000Z", isDraft: true }, + { ...PR, number: 12, linkedIssues: [], updatedAt: "2020-01-01T00:00:00.000Z" }, + ], + [{ id: "b2", repoFullName: REPO.fullName, issueNumber: 7, status: "stale bounty", payload: {} }], + { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: splitPreflight.lane, + issues: [ + { number: 7, title: "Issue", status: "needs_proof", score: 0, reasons: [], warnings: ["needs proof"] }, + { number: 8, title: "Issue2", status: "hold", score: 0, reasons: [], warnings: ["hold"] }, + ], + summary: "x", + }, + ); + expect(queue.findings.some((f) => f.code === "missing_test_evidence")).toBe(true); + expect(queue.findings.some((f) => f.code === "linked_issue_bounty_unverified")).toBe(true); + expect(queue.findings.some((f) => f.code === "issue_quality_needs_proof")).toBe(true); + expect(queue.findings.some((f) => f.code === "issue_quality_hold")).toBe(true); + + const collisionsForQueue = buildCollisionReport(REPO.fullName, [], [{ ...PR, number: 11, linkedIssues: [], updatedAt: "2020-01-01T00:00:00.000Z", isDraft: true }]); + const queueHealth = buildQueueHealth(REPO, [], [{ ...PR, number: 11, linkedIssues: [], updatedAt: "2020-01-01T00:00:00.000Z", isDraft: true }], collisionsForQueue); + expect(queueHealth.findings.some((f) => f.code === "unlinked_prs")).toBe(true); + expect(queueHealth.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + }); + + it("exercises gate holds, readiness score branches, and linked-issue advisory paths", () => { + const advisory = buildPullRequestAdvisory(REPO, PR, { requireLinkedIssue: true, confirmedNoOpenLinkedIssue: true, linkedIssueAuthorLogins: ["miner1"] }); + expect(advisory.findings.some((f) => f.code === "missing_linked_issue")).toBe(true); + expect(advisory.findings.some((f) => f.code === "self_authored_linked_issue")).toBe(true); + const guardrailHold = evaluateGateCheck( + { id: "a", targetType: "pull_request", targetKey: "k", repoFullName: REPO.fullName, conclusion: "success", severity: "info", title: "t", summary: "s", findings: [], generatedAt: "2026-01-01T00:00:00.000Z" }, + { guardrailHit: true, guardrailMatches: [{ path: "src/a.ts", glob: "src/*" }], sizeGateMode: "advisory", changedFileCount: 20, changedLineCount: 2000 }, + ); + expect(guardrailHold.conclusion).toBe("neutral"); + const preflight = buildPreflightResult({ repoFullName: REPO.fullName, title: "No issue docs only", body: "docs-only change", linkedIssues: [] }, REPO, [], []); + const readiness = buildPublicReadinessScore({ + pr: { ...PR, isDraft: true, body: "docs-only change", linkedIssues: [] }, + preflight, + queueHealth: buildQueueHealth(REPO, [], [], buildCollisionReport(REPO.fullName, [], [])), + scopedOverlapCount: 2, + linkedDuplicatePrs: [42], + }); + expect(readiness.total).toBeGreaterThan(0); + const slopBlocked = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "slop_risk_above_threshold", severity: "warning", title: "slop", detail: "slop" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { slopGateMode: "block", slopRisk: 90, slopGateMinScore: 60 }, + ); + expect(slopBlocked.blockers.some((b) => b.code === "slop_risk_above_threshold")).toBe(true); + }); + + it("exercises remaining advisory, duplicate-winner, and manifest branches", () => { + const discoveryOnlyRepo: RepositoryRecord = { + ...REPO, + registryConfig: { ...REPO.registryConfig!, issueDiscoveryShare: 1, maintainerCut: 1 }, + }; + const directOnlyRepo: RepositoryRecord = { + ...REPO, + registryConfig: { ...REPO.registryConfig!, issueDiscoveryShare: 0, maintainerCut: 0 }, + }; + expect(buildPullRequestAdvisory(discoveryOnlyRepo, PR).findings.some((f) => f.code === "direct_pr_pool_disabled")).toBe(true); + expect(buildPullRequestAdvisory(directOnlyRepo, PR).findings.some((f) => f.code === "issue_discovery_disabled")).toBe(true); + expect(buildPullRequestAdvisory(directOnlyRepo, PR).findings.some((f) => f.code === "maintainer_cut_enabled")).toBe(false); + expect(buildPullRequestAdvisory(discoveryOnlyRepo, PR).findings.some((f) => f.code === "maintainer_cut_enabled")).toBe(true); + + const busy = buildPullRequestAdvisory( + REPO, + PR, + { otherOpenPullRequests: Array.from({ length: 10 }, (_, i) => ({ ...PR, number: i + 20 })) }, + ); + expect(busy.findings.some((f) => f.code === "busy_pr_queue")).toBe(true); + expect(buildPullRequestAdvisory(REPO, { ...PR, authorAssociation: "OWNER" }).findings.some((f) => f.code === "maintainer_authored_pr")).toBe(true); + + const aiBlocked = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "ai_consensus_defect", severity: "warning", title: "ai", detail: "ai" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { aiReviewGateMode: "block", aiReviewCloseConfidence: 0.5 }, + ); + expect(aiBlocked.conclusion).toBe("failure"); + + const aiHold = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "success", + severity: "info", + title: "t", + summary: "s", + findings: [{ code: "ai_review_inconclusive", severity: "warning", title: "ai", detail: "ai" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + {}, + ); + expect(aiHold.conclusion).toBe("neutral"); + + expect( + isDuplicateClusterWinnerByClaim( + { number: 1, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }, + [{ number: 2, linkedIssueClaimedAt: "2026-01-02T00:00:00.000Z" }], + ), + ).toBe(true); + expect( + isDuplicateClusterWinnerByClaim( + { number: 2, createdAt: "2026-01-02T00:00:00.000Z" }, + [{ number: 1, createdAt: "2026-01-02T00:00:00.000Z" }], + ), + ).toBe(false); + + const preferredMissing = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: [], + preferredLabels: ["bug"], + linkedIssuePolicy: "preferred", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["src/a.ts"], + labels: [], + linkedIssueCount: 0, + passedValidationCount: 1, + }); + expect(preferredMissing.findings.some((f) => f.code === "manifest_linked_issue_preferred")).toBe(true); + expect(preferredMissing.findings.some((f) => f.code === "manifest_missing_preferred_label")).toBe(true); + }); + + it("exercises collision, bounty, readiness, and queue branches", () => { + const selfAuthoredSkip = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 1, linkedIssues: [], labels: [], authorLogin: "alice", title: "foo bar", changedFiles: ["src/services/upload/retry.ts"] }, + { ...PR, number: 2, linkedIssues: [], labels: [], authorLogin: "alice", title: "baz qux", changedFiles: ["src/services/upload/retry.ts"] }, + ]); + expect(selfAuthoredSkip.clusters).toHaveLength(0); + + const lockfileOnly = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 3, linkedIssues: [], labels: [], authorLogin: "bob", title: "foo bar", changedFiles: ["package-lock.json"] }, + { ...PR, number: 4, linkedIssues: [], labels: [], authorLogin: "carol", title: "baz qux", changedFiles: ["package-lock.json"] }, + ]); + expect(lockfileOnly.clusters).toHaveLength(0); + + const mergedCollisions = buildCollisionReport( + REPO.fullName, + [], + [], + [{ repoFullName: REPO.fullName, number: 99, title: "Merged fix", authorLogin: "miner1", labels: [], linkedIssues: [7], changedFiles: ["src/a.ts"] }], + ); + expect(mergedCollisions.summary.itemsReviewed).toBeGreaterThan(0); + + expect(classifyBountyLifecycle({ id: "b1", repoFullName: REPO.fullName, issueNumber: 7, status: "open", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }, { repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [] })).toBe("stale"); + expect(classifyBountyLifecycle({ id: "b3", repoFullName: REPO.fullName, issueNumber: 9, status: "open", updatedAt: new Date().toISOString(), discoveredAt: new Date().toISOString(), payload: {} }, { repoFullName: REPO.fullName, number: 9, title: "Issue", state: "open", labels: [], linkedPrs: [] })).toBe("active"); + expect(classifyBountyLifecycle({ id: "b2", repoFullName: REPO.fullName, issueNumber: 8, status: "active funded", updatedAt: "2026-01-01T00:00:00.000Z", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, { repoFullName: REPO.fullName, number: 8, title: "Issue", state: "closed", labels: [], linkedPrs: [] })).toBe("ambiguous"); + + const mergedSelfAuthored = buildCollisionReport( + REPO.fullName, + [], + [{ ...PR, number: 5, linkedIssues: [], labels: [], authorLogin: "alice", title: "foo bar", changedFiles: ["src/services/upload/retry.ts"] }], + [{ repoFullName: REPO.fullName, number: 50, title: "baz qux", authorLogin: "alice", labels: [], linkedIssues: [], changedFiles: ["src/services/upload/retry.ts"] }], + ); + expect(mergedSelfAuthored.clusters).toHaveLength(0); + + const linkedBodyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "Closes acme/widgets#77", linkedIssues: [] }, + REPO, + [], + [], + ); + expect(linkedBodyPreflight.linkedIssues).toContain(77); + + const holdPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7] }, + { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 0 } }, + [], + [], + [], + null, + false, + ); + const readyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: ["src/a.test.ts"] }, + REPO, + [], + [], + ); + const missingTestPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "tested locally", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: [] }, + REPO, + [], + [], + ); + const collisionReport = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 11, title: "overlap upload retry client", changedFiles: ["src/upload.ts"] }, + { ...PR, number: 12, title: "overlap upload retry service", changedFiles: ["src/upload.ts"] }, + ]); + expect(collisionReport.summary.clusterCount).toBeGreaterThan(0); + const queueHealth = buildQueueHealth( + REPO, + [], + Array.from({ length: 14 }, (_, i) => ({ ...PR, number: i + 20, linkedIssues: [7], updatedAt: i === 0 ? "2020-01-01T00:00:00.000Z" : "2026-06-01T00:00:00.000Z" })), + collisionReport, + ); + expect(queueHealth.findings.some((f) => f.code === "stale_prs")).toBe(true); + expect(queueHealth.findings.some((f) => f.code === "collision_clusters")).toBe(true); + + expect(buildPublicReadinessScore({ pr: { ...PR, labels: ["size:large"], isDraft: true }, preflight: holdPreflight, queueHealth }).total).toBeGreaterThan(0); + expect(buildPublicReadinessScore({ pr: { ...PR, body: "tested locally" }, preflight: missingTestPreflight, queueHealth }).components.find((c) => c.key === "validation")?.score).toBe(12); + expect(buildPublicReadinessScore({ pr: { ...PR, body: "npm test passed" }, preflight: readyPreflight, queueHealth }).components.find((c) => c.key === "validation")?.score).toBe(25); + expect(buildPublicReadinessScore({ pr: PR, preflight: readyPreflight, queueHealth }).components.find((c) => c.key === "validation")?.score).toBe(20); + + const union = unionScopedOverlapClusters(collisionReport, PR, collisionReport.clusters); + expect(union.length).toBeGreaterThanOrEqual(0); + + const malformed = buildFocusManifestGuidance({ + manifest: { + present: false, + source: "repo_file", + wantedPaths: [], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: ["run npm test"], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: false } as FocusManifest["gate"], + settings: {}, + review: { present: false, preMergeChecks: [] }, + warnings: ["invalid yaml"], + }, + changedPaths: ["src/a.ts"], + linkedIssueCount: 0, + testFileCount: 0, + passedValidationCount: 0, + }); + expect(malformed.findings.some((f) => f.code === "manifest_malformed")).toBe(true); + + const middleGlob = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: ["src/*util*core.ts"], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["src/foo/util/bar/core.ts"], + linkedIssueCount: 1, + testFileCount: 1, + }); + expect(middleGlob.matchedWantedPaths.length).toBeGreaterThan(0); + + expect(buildPullRequestAdvisory(REPO, { ...PR, state: "closed" }).findings.some((f) => f.code === "pr_not_open")).toBe(true); + const sizeHold = evaluateGateCheck( + { id: "a", targetType: "pull_request", targetKey: "k", repoFullName: REPO.fullName, conclusion: "success", severity: "info", title: "t", summary: "s", findings: [], generatedAt: "2026-01-01T00:00:00.000Z" }, + { sizeGateMode: "advisory", changedFileCount: 20, changedLineCount: 2000 }, + ); + expect(sizeHold.conclusion).toBe("neutral"); + expect(sizeHold.warnings.some((w) => w.code === "oversized_pr")).toBe(true); + }); + + it("mirrors engine cla-check and pre-merge-check branches", () => { + expect(evaluateClaCheck(claConfig(), { body: "no consent" })).toEqual([]); + expect(evaluateClaCheck(claConfig({ consentPhrase: "agree to the CLA" }), { body: "I agree to the CLA." })).toEqual([]); + expect(evaluateClaCheck(claConfig({ consentPhrase: "agree to the CLA" }), { body: "missing" })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(evaluateClaCheck(claConfig({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: "success" })).toEqual([]); + expect(evaluateClaCheck(claConfig({ checkRunName: "CLA Assistant Lite" }), { checkRunConclusion: undefined })[0]?.code).toBe(CLA_CHECK_UNRESOLVED_CODE); + expect(evaluateClaCheck(claConfig({ consentPhrase: "agree", checkRunName: "CLA Assistant Lite" }), { body: "no", checkRunConclusion: "failure" })[0]?.code).toBe( + CLA_CONSENT_MISSING_CODE, + ); + + expect(evaluatePreMergeChecks([], { title: "t", body: "b", labels: [], changedPaths: [] })).toEqual([]); + expect( + evaluatePreMergeChecks([preMergeCheck({ name: "All", titleContains: "FEAT", descriptionContains: "Migration", requireLabel: "Ship" })], { + title: "feat: add", + body: "includes a migration", + labels: ["ship"], + changedPaths: [], + }), + ).toEqual([]); + const advisoryFail = evaluatePreMergeChecks([preMergeCheck({ name: "Needs all", titleContains: "feat", descriptionContains: "why", requireLabel: "ready" })], { + title: "chore: x", + body: "no rationale", + labels: [], + changedPaths: [], + }); + expect(advisoryFail[0]?.code).toBe(PRE_MERGE_CHECK_ADVISORY_CODE); + const blockingFail = evaluatePreMergeChecks([preMergeCheck({ name: "Required", requireLabel: "approved", enforce: true })], { + title: "t", + body: "b", + labels: ["other"], + changedPaths: [], + }); + expect(blockingFail[0]?.code).toBe(PRE_MERGE_CHECK_BLOCKING_CODE); + const pathGated = evaluatePreMergeChecks( + [preMergeCheck({ name: "Migrations documented", whenPaths: ["migrations/**"], descriptionContains: "migration", enforce: true })], + { title: "t", body: "no note", labels: [], changedPaths: ["migrations/0099_x.sql"] }, + ); + expect(pathGated[0]?.code).toBe(PRE_MERGE_CHECK_BLOCKING_CODE); + const unresolved = evaluatePreMergeChecks( + [ + preMergeCheck({ name: "Migrations documented", whenPaths: ["migrations/**"], descriptionContains: "migration", enforce: true }), + preMergeCheck({ name: "advisory path check", whenPaths: ["migrations/**"], descriptionContains: "migration", enforce: false }), + preMergeCheck({ name: "JIRA in title", titleContains: "JIRA-", enforce: true }), + ], + { title: "no ref", body: "", labels: [], changedPaths: [], filesResolved: false }, + ); + expect(unresolved.find((f) => f.title.includes("Migrations documented"))?.code).toBe(PRE_MERGE_CHECK_UNRESOLVED_CODE); + expect(unresolved.find((f) => f.title.includes("JIRA in title"))?.code).toBe(PRE_MERGE_CHECK_BLOCKING_CODE); + expect(evaluatePreMergeChecks([preMergeCheck({ name: "T", titleContains: "feat" })], { changedPaths: [] })).toHaveLength(1); + }); + + it("exercises collision, duplicate-winner, and gate-evaluation edge branches", () => { + const sharedIssueCollision = buildCollisionReport( + REPO.fullName, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [], authorLogin: "other" }], + [ + { ...PR, number: 1, linkedIssues: [7] }, + { ...PR, number: 2, linkedIssues: [7] }, + ], + ); + expect(sharedIssueCollision.clusters.length).toBeGreaterThan(0); + + const pathOverlap = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 1, authorLogin: "alice", title: "alpha widget refactor", changedFiles: ["src/core/upload.ts"] }, + { ...PR, number: 2, authorLogin: "bob", title: "beta service cleanup", changedFiles: ["src/core/upload.ts"] }, + ]); + expect(pathOverlap.clusters.length).toBeGreaterThan(0); + + expect( + isDuplicateClusterWinnerByClaim({ number: 1, linkedIssueClaimedAt: "invalid" }, [{ number: 2, linkedIssueClaimedAt: "2026-01-02T00:00:00.000Z" }]), + ).toBe(false); + expect(resolveDuplicateClusterWinnerNumber({ number: 1, createdAt: null }, [{ number: 2, createdAt: null }])).toBeNull(); + + const unregistered = buildPullRequestAdvisory({ ...REPO, isRegistered: false, registryConfig: null }, PR); + expect(unregistered.findings.some((f) => f.code === "repo_unregistered")).toBe(true); + const missingConfig = buildPullRequestAdvisory({ ...REPO, registryConfig: null }, PR); + expect(missingConfig.findings.some((f) => f.code === "repo_config_missing")).toBe(true); + + const duplicateWinner = buildPullRequestAdvisory( + REPO, + { ...PR, number: 20, linkedIssues: [7], linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }, + { + otherOpenPullRequests: [{ ...PR, number: 21, linkedIssues: [7], linkedIssueClaimedAt: "2026-01-02T00:00:00.000Z" }], + duplicateWinnerEnabled: true, + }, + ); + expect(duplicateWinner.findings.some((f) => f.code === "duplicate_pr_risk")).toBe(false); + + const held = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "repo_not_registered", severity: "warning", title: "hold", detail: "hold" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + {}, + ); + expect(held.conclusion).toBe("neutral"); + + const claHeld = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: CLA_CHECK_UNRESOLVED_CODE, severity: "warning", title: "cla", detail: "cla" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { claGateMode: "block" }, + ); + expect(claHeld.conclusion).toBe("neutral"); + + const manifestBlocked = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "manifest_missing_tests", severity: "warning", title: "tests", detail: "tests", action: "add tests" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { manifestPolicyGateMode: "block" }, + ); + expect(manifestBlocked.conclusion).toBe("failure"); + + const mergeReady = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "missing_linked_issue", severity: "warning", title: "issue", detail: "issue" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { mergeReadinessGateMode: "block" }, + ); + expect(mergeReady.conclusion).toBe("failure"); + + const guardrailOnly = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "success", + severity: "info", + title: "t", + summary: "s", + findings: [], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + { guardrailHit: true, sizeGateMode: "off" }, + ); + expect(guardrailOnly.conclusion).toBe("neutral"); + expect(guardrailOnly.warnings.some((w) => w.code === "guardrail_hold")).toBe(true); + + const criticalBlocker = evaluateGateCheck( + { + id: "a", + targetType: "pull_request", + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral", + severity: "warning", + title: "t", + summary: "s", + findings: [{ code: "pre_merge_check_required", severity: "critical", title: "required", detail: "required", action: "fix it" }], + generatedAt: "2026-01-01T00:00:00.000Z", + }, + {}, + ); + expect(criticalBlocker.conclusion).toBe("failure"); + expect(criticalBlocker.summary).toContain("fix it"); + + const missingTests = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: [], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: ["paste your wallet hotkey here"], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["src/a.ts"], + linkedIssueCount: 1, + testFileCount: 0, + passedValidationCount: 0, + }); + expect(missingTests.findings.some((f) => f.code === "manifest_missing_tests")).toBe(true); + expect(missingTests.findings.find((f) => f.code === "manifest_missing_tests")?.detail).not.toContain("wallet"); + }); + + it("covers remaining codecov patch branch arms in ported engine modules", () => { + const splitRepo: RepositoryRecord = { + ...REPO, + registryConfig: { ...REPO.registryConfig!, issueDiscoveryShare: 0.5 }, + }; + expect(buildPullRequestAdvisory(splitRepo, PR).findings.some((f) => f.code === "issue_discovery_disabled")).toBe(false); + expect(buildPullRequestAdvisory(splitRepo, PR).findings.some((f) => f.code === "direct_pr_pool_disabled")).toBe(false); + expect(buildPullRequestAdvisory(null, null).findings.some((f) => f.code === "repo_not_registered")).toBe(true); + + expect(evaluateClaCheck(claConfig({ checkRunName: "CLA Bot" }), { checkRunConclusion: "failure" })[0]?.detail).toContain("CLA Bot"); + expect(evaluateClaCheck(claConfig({ consentPhrase: "agree" }), { body: "nope" })[0]?.detail).toContain("agree"); + + expect(isDuplicateClusterWinnerByClaim({ number: 1 }, [])).toBe(true); + expect(resolveDuplicateClusterWinnerNumber({ number: 1, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }, [])).toBe(1); + expect( + isDuplicateClusterWinnerByClaim( + { number: 1, createdAt: "2026-01-01T00:00:00.000Z" }, + [{ number: 2, linkedIssueClaimedAt: "2026-01-02T00:00:00.000Z" }], + ), + ).toBe(false); + + const pathological = "src/*-*-*-final.ts"; + expect(globToRegExp(pathological).test("src/a-b-c-final.ts")).toBe(false); + expect(guardrailPathMatches(["", "src/a.ts"], ["src/**"])).toEqual([{ path: "src/a.ts", glob: "src/**" }]); + expect(guardrailPathMatches(["scripts/x.ts"], [pathological])).toEqual([{ path: "scripts/x.ts", glob: pathological }]); + + expect(sanitizePublicComment("public reviewability score without prefix")).toContain("private context"); + + const prItem: CollisionItem = { type: "pull_request", number: 42, title: "Unrelated", linkedIssues: [9] }; + expect(itemSharesPlannedLinkedIssue(prItem, [9])).toBe(true); + expect(itemSharesPlannedLinkedIssue({ type: "pull_request", number: 9, title: "No links" }, [9])).toBe(false); + expect(termOverlap({ terms: new Set(), size: 0 }, { terms: new Set(["alpha"]), size: 1 }).score).toBe(0); + + const sharedIssueMedium = buildCollisionReport( + REPO.fullName, + [], + [{ ...PR, number: 1, linkedIssues: [7] }], + [{ repoFullName: REPO.fullName, number: 88, title: "Merged overlap", authorLogin: "bob", labels: [], linkedIssues: [7], changedFiles: ["src/a.ts"] }], + ); + expect(sharedIssueMedium.clusters.some((c) => c.risk === "medium")).toBe(true); + + const pathCollision = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 10, authorLogin: "alice", title: "upload retry client handler", labels: [], linkedIssues: [], changedFiles: ["src/core/upload.ts"] }, + { ...PR, number: 11, authorLogin: "carol", title: "upload retry service layer", labels: [], linkedIssues: [], changedFiles: ["src/core/upload.ts"] }, + ]); + expect(pathCollision.clusters.length).toBeGreaterThan(0); + + const mediumOnlyCollisions = buildCollisionReport( + REPO.fullName, + [], + [{ ...PR, number: 1, linkedIssues: [7] }], + [{ repoFullName: REPO.fullName, number: 88, title: "Merged overlap", authorLogin: "bob", labels: [], linkedIssues: [7], changedFiles: ["src/a.ts"] }], + ); + expect(mediumOnlyCollisions.summary.highRiskCount).toBe(0); + expect(mediumOnlyCollisions.summary.clusterCount).toBeGreaterThan(0); + + const queueInfoCollision = buildQueueHealth( + null, + [], + [{ ...PR, number: 14, linkedIssues: [], updatedAt: "2020-01-01T00:00:00.000Z", isDraft: true }], + mediumOnlyCollisions, + { openPullRequests: 20, likelyReviewablePullRequests: 5 }, + ); + expect(queueInfoCollision.repoFullName).toBe(REPO.fullName); + expect(queueInfoCollision.findings.find((f) => f.code === "collision_clusters")?.severity).toBe("info"); + expect(queueInfoCollision.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + + const issueQuality: IssueQualityReport = { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: REPO.fullName, summary: "direct", contributorGuidance: "direct", maintainerGuidance: "direct" }, + summary: "quality", + issues: [ + { number: 7, title: "Issue 7", status: "needs_proof", score: 40, reasons: [], warnings: ["needs more detail"] }, + { number: 8, title: "Issue 8", status: "do_not_use", score: 10, reasons: [], warnings: ["duplicate prone"] }, + ], + }; + const bountyPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: `Closes ${REPO.fullName}#77`, changedFiles: ["src/a.ts"], linkedIssues: [7, 8] }, + REPO, + [{ repoFullName: REPO.fullName, number: 7, title: "Issue", state: "open", labels: [], linkedPrs: [] }], + [], + [ + { id: "b1", repoFullName: REPO.fullName, issueNumber: 7, status: "closed", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }, + { id: "b2", repoFullName: REPO.fullName, issueNumber: 8, status: "open", updatedAt: "2020-01-01T00:00:00.000Z", discoveredAt: "2020-01-01T00:00:00.000Z", payload: {} }, + ], + issueQuality, + ); + expect(bountyPreflight.linkedIssues).toContain(77); + expect(bountyPreflight.findings.map((f) => f.code)).toEqual( + expect.arrayContaining(["linked_issue_bounty_historical", "linked_issue_bounty_unverified", "issue_quality_do_not_use", "issue_quality_needs_proof", "missing_test_evidence"]), + ); + + const mediumBurdenPreflight = buildPreflightResult( + { + repoFullName: REPO.fullName, + title: "Add pagination export endpoint", + body: "", + changedFiles: Array.from({ length: 12 }, (_, i) => `src/file-${i}.ts`), + linkedIssues: [7], + }, + REPO, + [{ repoFullName: REPO.fullName, number: 7, title: "Token refresh race", state: "open", labels: [], linkedPrs: [] }], + [{ ...PR, number: 50, linkedIssues: [7] }], + ); + expect(mediumBurdenPreflight.reviewBurden).toBe("high"); + expect(mediumBurdenPreflight.findings.some((f) => f.code === "possible_duplicate_work")).toBe(true); + + const globOverflowPattern = "**/".repeat(8) + "safe.ts"; + expect(matchesManifestPath("deep/nested/safe.ts", globOverflowPattern)).toBe(true); + + const middleMiss = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: ["src/foo/missing/bar/core.ts"], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["src/foo/wrong/bar/core.ts"], + linkedIssueCount: 1, + testFileCount: 1, + }); + expect(middleMiss.matchedWantedPaths).toHaveLength(0); + + const defaultLabelsGuidance = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: ["src/**"], + preferredLabels: ["bug"], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["", "src/a.ts"], + linkedIssueCount: 1, + testFileCount: 1, + }); + expect(defaultLabelsGuidance.preferredLabelHits).toEqual([]); + + const advisoryBase = { + id: "a", + targetType: "pull_request" as const, + targetKey: "k", + repoFullName: REPO.fullName, + conclusion: "neutral" as const, + severity: "warning" as const, + title: "t", + summary: "s", + generatedAt: "2026-01-01T00:00:00.000Z", + }; + + expect( + evaluateGateCheck( + { ...advisoryBase, findings: [{ code: "repo_not_seen", severity: "warning", title: "hold", detail: "hold" }] }, + {}, + ).conclusion, + ).toBe("neutral"); + + const dryRun = evaluateGateCheck( + { ...advisoryBase, conclusion: "success", severity: "info", findings: [] }, + { dryRun: true, aiReviewGateMode: "advisory" }, + ); + expect(dryRun.displayConclusion).toBeDefined(); + + const multiBlocker = evaluateGateCheck( + { + ...advisoryBase, + findings: [ + { code: "missing_linked_issue", severity: "warning", title: "issue", detail: "issue", action: "link one" }, + { code: "duplicate_pr_risk", severity: "warning", title: "dup", detail: "dup" }, + ], + }, + { linkedIssueGateMode: "block", duplicatePrGateMode: "block" }, + ); + expect(multiBlocker.conclusion).toBe("failure"); + expect(multiBlocker.title).toContain("2 blockers"); + + const policyBlockers = evaluateGateCheck( + { + ...advisoryBase, + findings: [ + { code: REVIEW_THREAD_BLOCKER_CODE, severity: "warning", title: "thread", detail: "thread" }, + { code: "secret_leak", severity: "critical", title: "secret", detail: "secret", action: "rotate" }, + { code: "self_authored_linked_issue", severity: "warning", title: "self", detail: "self" }, + { code: "lockfile_tamper_risk", severity: "warning", title: "lock", detail: "lock" }, + { code: CLA_CONSENT_MISSING_CODE, severity: "warning", title: "cla", detail: "cla" }, + { code: "ai_review_split", severity: "warning", title: "split", detail: "split" }, + ], + }, + { + selfAuthoredLinkedIssueGateMode: "block", + lockfileIntegrityGateMode: "block", + claGateMode: "block", + aiReviewGateMode: "block", + }, + ); + expect(policyBlockers.blockers.map((b) => b.code)).toEqual( + expect.arrayContaining([REVIEW_THREAD_BLOCKER_CODE, "secret_leak", "self_authored_linked_issue", "lockfile_tamper_risk", CLA_CONSENT_MISSING_CODE, "ai_review_split"]), + ); + + const advisoryDuplicate = evaluateGateCheck( + { ...advisoryBase, findings: [{ code: "duplicate_pr_risk", severity: "warning", title: "dup", detail: "dup" }] }, + { duplicatePrGateMode: "advisory" }, + ); + expect(advisoryDuplicate.conclusion).toBe("success"); + + const qualityWarn = evaluateGateCheck( + { ...advisoryBase, conclusion: "success", severity: "info", findings: [] }, + { qualityGateMode: "advisory", readinessScore: 40, qualityGateMinScore: 70 }, + ); + expect(qualityWarn.warnings.some((w) => w.code === "readiness_score_below_threshold")).toBe(true); + + const slopBelow = evaluateGateCheck( + { ...advisoryBase, conclusion: "success", severity: "info", findings: [] }, + { slopGateMode: "block", slopRisk: 10, slopGateMinScore: 60 }, + ); + expect(slopBelow.conclusion).toBe("success"); + + expect(gateAdvisoryInternals.highestSeverity([{ code: "x", severity: "critical", title: "c", detail: "c" }])).toBe("critical"); + expect( + gateAdvisoryInternals.conclusionForSeverity("critical", [{ code: "x", severity: "critical", title: "c", detail: "c" }]), + ).toBe("action_required"); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "advisory", changedFileCount: 1, changedLineCount: 1 })).toBeNull(); + expect(gateAdvisoryInternals.promoteAdvisoryToBlock({ aiReviewGateMode: "advisory" }).aiReviewGateMode).toBe("block"); + + const dryRunAi = evaluateGateCheck( + { + ...advisoryBase, + conclusion: "success", + severity: "info", + findings: [{ code: "ai_consensus_defect", severity: "warning", title: "ai", detail: "ai" }], + }, + { dryRun: true, aiReviewGateMode: "advisory" }, + ); + expect(dryRunAi.displayConclusion).toBe("failure"); + + const sampledQueue = buildQueueHealth(REPO, [], [{ ...PR, number: 1, linkedIssues: [7], updatedAt: "2026-06-01T00:00:00.000Z" }], buildCollisionReport(REPO.fullName, [], []), { + openPullRequests: 25, + }); + expect(sampledQueue.signals.likelyReviewablePullRequestsSource).toBe("sampled_cache"); + expect( + buildPublicReadinessScore({ + pr: PR, + preflight: buildPreflightResult({ repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7] }, REPO, [], []), + queueHealth: sampledQueue, + }).components.find((c) => c.key === "queue_pressure")?.evidence, + ).toContain("sampled"); + + const selfAuthoredSkip = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 1, linkedIssues: [], labels: [], authorLogin: "alice", title: "alpha upload retry", changedFiles: ["src/core/upload.ts"] }, + { ...PR, number: 2, linkedIssues: [], labels: [], authorLogin: "alice", title: "beta service layer", changedFiles: ["src/core/upload.ts"] }, + ]); + expect(selfAuthoredSkip.clusters).toHaveLength(0); + + const existingCluster = buildCollisionReport(REPO.fullName, [], [ + { ...PR, number: 1, linkedIssues: [7] }, + { ...PR, number: 2, linkedIssues: [7] }, + { ...PR, number: 3, linkedIssues: [7] }, + ]); + expect(existingCluster.clusters.length).toBeGreaterThan(0); + + expect( + isDuplicateClusterWinnerByClaim({ number: 1, linkedIssueClaimedAt: "2026-01-01T00:00:00.000Z" }, [{ number: 2, linkedIssueClaimedAt: undefined }]), + ).toBe(false); + + expect(evaluateClaCheck({ consentPhrase: "agree", checkRunName: null }, { body: "nope" })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(guardrailPathMatches(["src/a.ts"], ["src/a.ts"])).toEqual([{ path: "src/a.ts", glob: "src/a.ts" }]); + + const noLinkedCountGuidance = buildFocusManifestGuidance({ + manifest: { + present: true, + source: "repo_file", + wantedPaths: ["src/**"], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { present: true } as FocusManifest["gate"], + settings: {}, + review: { present: true, preMergeChecks: [] }, + warnings: [], + }, + changedPaths: ["src/a.ts"], + testFileCount: 1, + }); + expect(noLinkedCountGuidance.findings).toBeDefined(); + + expect(classifyBountyLifecycle({ id: "b", repoFullName: REPO.fullName, issueNumber: 1, status: " ", updatedAt: "2026-01-01T00:00:00.000Z", discoveredAt: "2026-01-01T00:00:00.000Z", payload: {} }, null)).toBe("unknown"); + + const overlapPreflight = buildPreflightResult( + { + repoFullName: REPO.fullName, + title: "Resolve login redirect loop OAuth callback handler", + body: "", + changedFiles: ["src/auth.ts"], + linkedIssues: [], + }, + REPO, + [{ repoFullName: REPO.fullName, number: 51, title: "Login redirect loop OAuth cleanup", state: "open", labels: [], linkedPrs: [] }], + [{ ...PR, number: 52, title: "Login redirect loop OAuth middleware", linkedIssues: [], changedFiles: ["src/auth.ts"] }], + ); + expect(overlapPreflight.findings.some((f) => f.code === "possible_duplicate_work")).toBe(true); + + const holdQualityPreflight = buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [9], changedFiles: ["src/a.ts"], tests: [] }, + REPO, + [], + [], + [], + { + repoFullName: REPO.fullName, + generatedAt: "2026-01-01T00:00:00.000Z", + lane: { lane: "direct_pr", repoFullName: REPO.fullName, summary: "direct", contributorGuidance: "direct", maintainerGuidance: "direct" }, + summary: "quality", + issues: [{ number: 9, title: "Hold", status: "hold", score: 50, reasons: [], warnings: ["on hold"] }], + }, + ); + expect(holdQualityPreflight.findings.some((f) => f.code === "issue_quality_hold")).toBe(true); + + const inactiveDraftQueue = buildQueueHealth( + REPO, + [], + [{ ...PR, number: 99, isDraft: true, updatedAt: "2000-01-01T00:00:00.000Z", linkedIssues: [] }], + buildCollisionReport(REPO.fullName, [], []), + ); + expect(inactiveDraftQueue.findings.some((f) => f.code === "inactive_draft_prs")).toBe(true); + + const nonBlockers = evaluateGateCheck( + { + ...advisoryBase, + findings: [ + { code: "missing_linked_issue", severity: "warning", title: "issue", detail: "issue" }, + { code: "ai_consensus_defect", severity: "warning", title: "ai", detail: "ai" }, + { code: "manifest_missing_tests", severity: "warning", title: "tests", detail: "tests" }, + { code: "self_authored_linked_issue", severity: "warning", title: "self", detail: "self" }, + { code: "lockfile_tamper_risk", severity: "warning", title: "lock", detail: "lock" }, + { code: CLA_CONSENT_MISSING_CODE, severity: "warning", title: "cla", detail: "cla" }, + ], + }, + { + linkedIssueGateMode: "advisory", + aiReviewGateMode: "advisory", + manifestPolicyGateMode: "off", + selfAuthoredLinkedIssueGateMode: "advisory", + lockfileIntegrityGateMode: "off", + claGateMode: "off", + qualityGateMode: "advisory", + readinessScore: 30, + qualityGateMinScore: 70, + }, + ); + expect(nonBlockers.conclusion).toBe("success"); + expect(nonBlockers.warnings.some((w) => w.code === "readiness_score_below_threshold")).toBe(true); + + const sizeHoldLines = evaluateGateCheck( + { ...advisoryBase, conclusion: "success", severity: "info", findings: [] }, + { sizeGateMode: "advisory", changedFileCount: 12, changedLineCount: 50 }, + ); + expect(sizeHoldLines.warnings.some((w) => w.code === "oversized_pr")).toBe(true); + + const policy = { linkedIssueGateMode: "advisory" as const, duplicatePrGateMode: "advisory" as const, aiReviewGateMode: "advisory" as const, manifestPolicyGateMode: "off" as const, selfAuthoredLinkedIssueGateMode: "advisory" as const, lockfileIntegrityGateMode: "off" as const, claGateMode: "off" as const }; + const blockPolicy = { linkedIssueGateMode: "block" as const, duplicatePrGateMode: "block" as const, aiReviewGateMode: "block" as const, manifestPolicyGateMode: "block" as const, selfAuthoredLinkedIssueGateMode: "block" as const, lockfileIntegrityGateMode: "block" as const, claGateMode: "block" as const }; + const finding = (code: string) => ({ code, severity: "warning" as const, title: code, detail: code }); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("missing_linked_issue"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("missing_linked_issue"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("duplicate_pr_risk"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("duplicate_pr_risk"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("ai_consensus_defect"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("ai_consensus_defect"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_missing_tests"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("manifest_missing_tests"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("self_authored_linked_issue"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("self_authored_linked_issue"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("lockfile_tamper_risk"), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding("lockfile_tamper_risk"), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding(CLA_CONSENT_MISSING_CODE), policy)).toBe(false); + expect(gateAdvisoryInternals.isConfiguredGateBlocker(finding(CLA_CONSENT_MISSING_CODE), blockPolicy)).toBe(true); + expect(gateAdvisoryInternals.buildSlopGateBlocker({ slopGateMode: "block", slopRisk: null })).toBeNull(); + expect(gateAdvisoryInternals.buildSlopGateBlocker({ slopGateMode: "block", slopRisk: 80, slopGateMinScore: 60 })?.code).toBe("slop_risk_above_threshold"); + expect(gateAdvisoryInternals.buildSizeHoldFinding({ sizeGateMode: "advisory", changedFileCount: 5, changedLineCount: 2000 })?.code).toBe("oversized_pr"); + expect(gateAdvisoryInternals.promoteAdvisoryToBlock({ aiReviewGateMode: "block" }).aiReviewGateMode).toBe("block"); + + expect(evaluateClaCheck({ consentPhrase: "agree", checkRunName: null }, { body: "nope" })[0]?.code).toBe(CLA_CONSENT_MISSING_CODE); + expect(matchesAny("src/a.ts", ["src/a.ts"])).toBe(true); + + expect(predictedGateEngineInternals.sharesMeaningfulFile(["src/a.ts"], ["src/a.ts"])).toBe(true); + expect(predictedGateEngineInternals.sharesMeaningfulFile(undefined, ["src/a.ts"])).toBe(false); + expect(predictedGateEngineInternals.truncateText("short", 10)).toBe("short"); + expect(predictedGateEngineInternals.truncateText("x".repeat(20), 10)).toHaveLength(10); + expect(predictedGateEngineInternals.extractLinkedIssueNumbers(`closes ${REPO.fullName}#42`, REPO.fullName)).toContain(42); + + const failureWithQuality = evaluateGateCheck( + { ...advisoryBase, findings: [{ code: "missing_linked_issue", severity: "warning", title: "issue", detail: "issue", action: "link it" }] }, + { linkedIssueGateMode: "block", qualityGateMode: "advisory", readinessScore: 10, qualityGateMinScore: 50 }, + ); + expect(failureWithQuality.conclusion).toBe("failure"); + expect(failureWithQuality.warnings.some((w) => w.code === "readiness_score_below_threshold")).toBe(true); + + const readinessHold = buildPublicReadinessScore({ + pr: { ...PR, labels: ["size:large"], isDraft: true, body: "tested locally" }, + preflight: buildPreflightResult( + { repoFullName: REPO.fullName, title: "Fix", body: "", linkedIssues: [7], changedFiles: ["src/a.ts"], tests: [] }, + { ...REPO, registryConfig: { ...REPO.registryConfig!, emissionShare: 0 } }, + [], + [], + [], + null, + false, + ), + queueHealth: buildQueueHealth(REPO, [], [{ ...PR, number: 30, linkedIssues: [7], updatedAt: "2026-06-01T00:00:00.000Z" }], buildCollisionReport(REPO.fullName, [], []), { openPullRequests: 30 }), + }); + expect(readinessHold.components.find((c) => c.key === "change_scope")?.score).toBe(20); + expect(readinessHold.components.find((c) => c.key === "validation")?.score).toBe(5); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 61b6a6fba5..2df10ca8fe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ ...(junitPath ? { outputFile: { junit: junitPath } } : {}), coverage: { provider: "v8", - include: ["src/**/*.ts", "review-enrichment/src/analyzers/codeowners.ts"], + include: ["src/**/*.ts", "packages/gittensory-engine/src/**/*.ts", "review-enrichment/src/analyzers/codeowners.ts"], exclude: ["src/env.d.ts", "apps/**"], // Emit lcov for Codecov to compute patch (changed-lines) coverage. reporter: ["text", "lcov"],