From caaedc79694ba633512f921adccea77b392223e5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:27:41 -0700 Subject: [PATCH 1/4] types(mcp): type the stdio CLI's option plumbing, fixing three crashes it hid (#9773) `parseOptions` is now typed from CLI_FLAG_SPEC -- repeatable flags as arrays, boolean flags as booleans, anything else as `string | boolean` behind an index signature, because the parser genuinely accepts any `--flag` and a closed record would be a lie. That type flows into every `options` parameter, every argv parameter becomes `readonly string[]`, and the config parameters take the contract's LoopoverConfig. Three defects fell out immediately, each reproduced against main before the fix: TypeError: (options[key] ?? []) is not iterable repoFullName.includes is not a function LoopOver API 404: {"error":"not_found"} The first is `--issue --issue 5`: a bare repeatable flag is stored as `true` by the no-value branch, and the accumulator then spread it. Anything not already a list now starts a fresh one -- the only sane reading of a flag that carried no value to keep. The second is `maintain --repo` with no value. `true` passed the `!repoFullName` truthiness guard and then died on a string method, where "Pass --repo owner/repo." was intended. The third is a bare `--login`, read as the literal string "true", so `decision-pack --login` requested a contributor NAMED "true" and reported them not found instead of saying the value was missing. Options are read through optionText() now, which treats a valueless flag as absent -- and every one of those call sites already had an env or profile fallback for absent. Also: the contract's LoopoverConfig was missing `session`, `telemetryEnabled`, and profile `createdAt`, all three read and written by the CLI with nothing checking they existed. The legacy top-level `session` is still written on the default profile so an older CLI reading the same file keeps working, which is exactly why it cannot be left undeclared. 277 -> 184 `: any` occurrences in the bin. The remainder is a long tail of callbacks over API payloads that stay untyped for a structural reason worth its own issue: CLI_RESPONSE_SCHEMAS covers only the 24 STATIC paths, so all 53 parameterised calls fall through to the untyped overload. #9773 stays open for that. --- packages/loopover-contract/src/api-schemas.ts | 472 ++++++++++++++++++ packages/loopover-contract/src/cli-config.ts | 16 +- packages/loopover-mcp/bin/loopover-mcp.ts | 342 ++++++++----- scripts/gen-contract-api-schemas.ts | 93 +++- test/unit/mcp-cli-bool-flag-parsing.test.ts | 24 + ...-cli-contributor-profile-inprocess.test.ts | 14 +- test/unit/mcp-cli-contributor-profile.test.ts | 8 +- test/unit/support/mcp-cli-harness.ts | 6 +- 8 files changed, 829 insertions(+), 146 deletions(-) diff --git a/packages/loopover-contract/src/api-schemas.ts b/packages/loopover-contract/src/api-schemas.ts index b71d72fc78..39ffdec117 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -84,6 +84,189 @@ export const CollisionClusterSchema = z items: z.array(CollisionItemSchema), }); +export const ContributorProfileSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + github: z.object({ + login: z.string(), + name: z.string().nullable().optional(), + bio: z.string().nullable().optional(), + company: z.string().nullable().optional(), + publicRepos: z.number().optional(), + followers: z.number().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + topLanguages: z.array(z.string()), + source: z.enum(["github", "unavailable"]), + }), + source: z.enum(["gittensor_api", "github_cache"]), + gittensor: z + .object({ + githubId: z.string(), + githubUsername: z.string(), + uid: z.number().optional(), + hotkey: z.string().optional(), + evaluatedAt: z.string().optional(), + updatedAt: z.string().optional(), + isEligible: z.boolean(), + credibility: z.number(), + eligibleRepoCount: z.number(), + issueDiscoveryScore: z.number(), + issueTokenScore: z.number(), + issueCredibility: z.number(), + isIssueEligible: z.boolean(), + issueEligibleRepoCount: z.number(), + alphaPerDay: z.number(), + taoPerDay: z.number(), + usdPerDay: z.number(), + totals: z.object({ + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + closedPullRequests: z.number(), + openIssues: z.number(), + closedIssues: z.number(), + solvedIssues: z.number(), + validSolvedIssues: z.number(), + }), + repositories: z.array( + z.object({ + repoFullName: z.string(), + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + closedPullRequests: z.number(), + openIssues: z.number(), + closedIssues: z.number(), + solvedIssues: z.number(), + validSolvedIssues: z.number(), + isEligible: z.boolean(), + isIssueEligible: z.boolean(), + credibility: z.number(), + issueCredibility: z.number(), + totalScore: z.number(), + baseTotalScore: z.number(), + }), + ), + }) + .optional(), + registeredRepoActivity: z.object({ + pullRequests: z.number(), + mergedPullRequests: z.number(), + issues: z.number(), + reposTouched: z.array(z.string()), + dominantLabels: z.array(z.string()), + }), + trustSignals: z.object({ + evidenceScore: z.number(), + level: z.enum(["new", "emerging", "established"]), + unlinkedOpenPullRequests: z.number(), + maintainerAssociatedPullRequests: z.number(), + }), + }); + +export const ContributorOpenPrNextStepPacketSchema = z + .object({ + repoFullName: z.string(), + number: z.number(), + title: z.string(), + classification: z.enum([ + "approved", + "blocked", + "stale", + "needs_author", + "failing_checks", + "missing_tests", + "duplicate_prone", + "reviewable", + "should_close_or_withdraw", + "maintainer_lane", + "draft", + ]), + summary: z.string(), + reasons: z.array(z.string()), + nextSteps: z.array(z.string()), + }); + +export const ContributorOpenPrMonitorSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + openPrCount: z.number(), + registeredRepoCount: z.number(), + cleanupFirst: z.boolean(), + summary: z.string(), + guidance: z.array(z.string()), + pendingScenarios: z.array( + z.object({ + repoFullName: z.string(), + detection: z.object({ + source: z.enum(["github_observed", "user_supplied"]), + pendingMergedPrCount: z.number(), + pendingClosedPrCount: z.number(), + approvedPrCount: z.number(), + expectedOpenPrCountAfterMerge: z.number().optional(), + scenarioNotes: z.array(z.string()), + classified: z.array( + z.object({ + repoFullName: z.string(), + number: z.number(), + title: z.string(), + classification: z.string(), + reasons: z.array(z.string()), + }), + ), + }), + }), + ), + pullRequests: z.array(ContributorOpenPrNextStepPacketSchema), + }); + +export const NotificationFeedItemSchema = z + .object({ + id: z.string(), + eventType: z.string(), + repoFullName: z.string(), + pullNumber: z.number().nullable(), + title: z.string(), + body: z.string(), + deeplink: z.string(), + status: z.enum(["delivered", "read"]), + createdAt: z.string(), + }); + +export const NotificationFeedSchema = z + .object({ + login: z.string(), + unreadCount: z.number(), + notifications: z.array(NotificationFeedItemSchema), + }); + +export const NotificationsMarkedSchema = z + .object({ + login: z.string(), + marked: z.number(), + }); + +/** + * Request body for POST/DELETE /v1/contributors/{login}/watches. Mirrors `watchSubscriptionBodySchema` + * in src/api/routes.ts (repoFullName + optional labels) — #9306. + */ +export const ContributorOpportunitySchema = z + .object({ + repoFullName: z.string(), + issueNumber: z.number().optional(), + title: z.string(), + fit: z.enum(["good", "caution", "hold"]), + score: z.number(), + lane: z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"]), + multiplierTier: z.enum(["maintainer_created", "community"]), + availability: z.enum(["ready", "maintainer_wip"]), + reasons: z.array(z.string()), + warnings: z.array(z.string()), + }); + export const PreflightResultSchema = z .object({ repoFullName: z.string(), @@ -107,6 +290,36 @@ export const LocalDiffPreflightResultSchema = PreflightResultSchema.extend({ }), }); +export const BountySourceContextSchema = z.object({ + sourceUrl: z.string().nullable().optional(), + discoveredAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + observedAt: z.string().nullable().optional(), + ageDays: z.number().nullable(), + freshness: z.enum(["fresh", "stale", "unknown"]), +}); + +export const BountyLinkedPrSchema = z.object({ + number: z.number(), + state: z.enum(["open", "closed", "merged", "unknown"]), + isActive: z.boolean(), +}); + +export const BountyAdvisorySchema = z + .object({ + id: z.string(), + repoFullName: z.string(), + issueNumber: z.number(), + status: z.string(), + lifecycle: z.enum(["active", "historical", "completed", "cancelled", "stale", "ambiguous", "unknown"]), + isActiveOpportunity: z.boolean(), + fundingStatus: z.enum(["funded", "target_only", "unknown"]), + consensusRisk: z.enum(["low", "medium", "high"]), + source: BountySourceContextSchema, + linkedPrs: z.array(BountyLinkedPrSchema), + findings: z.array(FindingSchema), + }); + export const SkippedPrAuditExportSchema = z .object({ generatedAt: z.string(), @@ -409,6 +622,222 @@ export const ReviewRiskExplanationSchema = z summary: z.string(), }); +export const ContributorOutcomeCountsSchema = z.object({ + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + closedPullRequests: z.number(), + issues: z.number(), + openIssues: z.number(), + closedIssues: z.number(), + solvedIssues: z.number(), + validSolvedIssues: z.number(), +}); + +export const ContributorOutcomeTotalsSchema = ContributorOutcomeCountsSchema.extend({ + closedPullRequestRate: z.number(), + credibility: z.number(), + issueCredibility: z.number(), +}); + +export const ContributorReconciliationReportSchema = z.object({ + login: z.string(), + generatedAt: z.string(), + source: z.enum(["gittensor_api", "github_cache"]), + officialAuthoritative: z.boolean(), + totals: z.object({ + official: ContributorOutcomeTotalsSchema.optional(), + cached: ContributorOutcomeTotalsSchema, + effective: ContributorOutcomeTotalsSchema, + }), + repos: z.array( + z.object({ + repoFullName: z.string(), + maintainerLane: z.boolean(), + official: ContributorOutcomeCountsSchema.optional(), + cached: ContributorOutcomeCountsSchema, + effective: ContributorOutcomeCountsSchema, + discrepancyReasons: z.array(z.string()), + freshness: z.object({ + officialUpdatedAt: z.string().optional(), + cachedLastActivityAt: z.string().optional(), + }), + }), + ), + findings: z.array(FindingSchema), + summary: z.string(), +}); + +export const ContributorOutcomeHistorySchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + source: z.enum(["gittensor_api", "github_cache"]), + reconciliation: ContributorReconciliationReportSchema.optional(), + totals: z.record(z.string(), z.number()), + repoOutcomes: z.array(z.record(z.string(), z.unknown())), + successPatterns: z.array(z.record(z.string(), z.unknown())), + failurePatterns: z.array(z.record(z.string(), z.unknown())), + summary: z.string(), + }); + +export const DecisionPackFreshnessSchema = z.enum(["fresh", "stale", "rebuilding", "missing"]); + +export const AgentRecommendationOutcomeStateSchema = z.enum(["accepted", "rejected", "ignored", "stale", "merged", "closed", "improved"]); + +export const AgentRecommendationOutcomeStateBucketSchema = z + .object({ + state: AgentRecommendationOutcomeStateSchema, + count: z.number(), + }); + +export const AgentRecommendationOutcomeRepoSummarySchema = z + .object({ + repoFullName: z.string(), + total: z.number(), + accepted: z.number(), + rejected: z.number(), + ignored: z.number(), + stale: z.number(), + merged: z.number(), + closed: z.number(), + improved: z.number(), + positive: z.number(), + negative: z.number(), + maintainerLaneTotal: z.number(), + latestOutcomeAt: z.string().nullable().optional(), + signal: z.enum(["positive", "negative", "mixed", "neutral"]), + }); + +export const AgentRecommendationOutcomeSummarySchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + windowDays: z.number(), + totals: z.object({ + total: z.number(), + accepted: z.number(), + rejected: z.number(), + ignored: z.number(), + stale: z.number(), + merged: z.number(), + closed: z.number(), + improved: z.number(), + positive: z.number(), + negative: z.number(), + maintainerLaneTotal: z.number(), + }), + sources: z.object({ + explicit: z.number(), + inferred: z.number(), + }), + states: z.array(AgentRecommendationOutcomeStateBucketSchema), + repos: z.array(AgentRecommendationOutcomeRepoSummarySchema), + maintainerLane: z.object({ + total: z.number(), + states: z.array(AgentRecommendationOutcomeStateBucketSchema), + }), + privateSummary: z.string(), + }); + +export const DecisionRecommendationSchema = z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "watch"]); + +export const DecisionActionKindSchema = z + .enum(["cleanup_existing_prs", "land_existing_prs", "open_new_direct_pr", "file_issue_discovery", "maintainer_lane_improve_repo", "maintainer_cut_readiness"]); + +export const ActionPortfolioBucketNameSchema = z.enum(["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"]); + +export const ActionPortfolioItemSchema = z + .object({ + bucket: ActionPortfolioBucketNameSchema, + repoFullName: z.string(), + actionKind: DecisionActionKindSchema.optional(), + priorityScore: z.number(), + recommendation: DecisionRecommendationSchema, + status: z.enum(["recommended", "blocked", "watch"]), + whyNow: z.array(z.string()), + scoreabilityImpact: z.string(), + riskImpact: z.string(), + maintainerImpact: z.string(), + blockedBy: z.array(z.string()), + rerunWhen: z.string(), + publicSafeSummary: z.string(), + nextActions: z.array(z.string()), + publicNextActions: z.array(z.string()), + source: z.enum(["decision_pack"]), + scenarioProjection: z + .object({ + source: z.enum(["github_observed", "user_supplied"]), + pendingMergedPrCount: z.number(), + pendingClosedPrCount: z.number(), + approvedPrCount: z.number(), + expectedOpenPrCountAfterMerge: z.number().optional(), + notes: z.array(z.string()), + }) + .optional(), + }); + +export const ActionPortfolioSchema = z + .object({ + generatedAt: z.string(), + bucketOrder: z.array(ActionPortfolioBucketNameSchema), + buckets: z.array( + z.object({ + bucket: ActionPortfolioBucketNameSchema, + label: z.string(), + summary: z.string(), + actions: z.array(ActionPortfolioItemSchema), + }), + ), + topActions: z.array(ActionPortfolioItemSchema), + counts: z.record(z.string(), z.number()), + summary: z.string(), + }); + +export const ContributorDecisionPackSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["computed", "snapshot"]), + login: z.string(), + generatedAt: z.string(), + snapshotAgeSeconds: z.number().optional(), + stale: z.boolean(), + freshness: DecisionPackFreshnessSchema, + rebuildEnqueued: z.boolean(), + scoringModelSnapshotId: z.string(), + profile: z.record(z.string(), z.unknown()), + outcomeHistory: ContributorOutcomeHistorySchema, + roleContexts: z.array(RoleContextSchema), + opportunities: z.array(ContributorOpportunitySchema), + repoDecisions: z.array(z.record(z.string(), z.unknown())), + topActions: z.array(z.record(z.string(), z.unknown())), + actionPortfolio: ActionPortfolioSchema, + cleanupFirst: z.array(z.record(z.string(), z.unknown())), + pursueRepos: z.array(z.record(z.string(), z.unknown())), + avoidRepos: z.array(z.record(z.string(), z.unknown())), + maintainerLaneRepos: z.array(z.record(z.string(), z.unknown())), + scoreBlockers: z.array(z.record(z.string(), z.unknown())), + recommendationOutcomeFeedback: AgentRecommendationOutcomeSummarySchema, + evidenceGraph: z.record(z.string(), z.unknown()).optional(), + dataQuality: z.record(z.string(), z.unknown()), + summary: z.string(), + nextActions: z.array(z.string()), + openPrMonitor: ContributorOpenPrMonitorSchema.optional(), + }); + +export const RepoDecisionResponseSchema = z + .object({ + status: z.enum(["ready"]), + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + source: z.enum(["computed", "snapshot"]), + freshness: DecisionPackFreshnessSchema, + rebuildEnqueued: z.boolean(), + decision: z.record(z.string(), z.unknown()), + dataQuality: z.record(z.string(), z.unknown()), + }); + export const RewardRiskActionSchema = z .object({ actionKind: z.enum([ @@ -883,8 +1312,51 @@ export const CLI_RESPONSE_SCHEMAS = { "/v1/validate/focus-manifest": ValidateFocusManifestResponseSchema, } as const; +/** + * The same, for the PARAMETERISED paths (#9773) -- keyed by the document's own `{param}` template. + * + * Separate from the table above because these cannot be looked up by an exact string: the CLI builds them + * with interpolation, so the match happens at the type level (see MatchApiPath) rather than by key. + */ +export const CLI_PARAMETERISED_RESPONSE_SCHEMAS = { + "/v1/agent/runs/{id}": AgentRunBundleSchema, + "/v1/bounties/{id}/advisory": BountyAdvisorySchema, + "/v1/contributors/{login}/decision-pack": ContributorDecisionPackSchema, + "/v1/contributors/{login}/notifications": NotificationFeedSchema, + "/v1/contributors/{login}/notifications/read": NotificationsMarkedSchema, + "/v1/contributors/{login}/open-pr-monitor": ContributorOpenPrMonitorSchema, + "/v1/contributors/{login}/profile": ContributorProfileSchema, + "/v1/contributors/{login}/repos/{owner}/{repo}/decision": RepoDecisionResponseSchema, +} as const; + /** A path the client validates. */ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; /** The parsed response type for a validated path -- what the CLI call sites get instead of `any`. */ export type ApiResponse = z.infer<(typeof CLI_RESPONSE_SCHEMAS)[Path]>; + +/** A parameterised path pattern the client validates. */ +export type ParameterisedApiPath = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; + +/** + * A pattern with every `{param}` widened to `${string}`, so a concrete path can be matched against it. + * + * Recursive because a pattern can carry several parameters + * (`/v1/contributors/{login}/repos/{owner}/{repo}/decision`). + */ +export type TemplatedApiPath = Pattern extends `${infer Head}{${string}}${infer Tail}` + ? `${Head}${string}${TemplatedApiPath}` + : Pattern; + +/** + * The pattern a CONCRETE path matches, or `never` when it matches none. + * + * This is what lets the CLI keep writing its natural interpolated template and still get the exact response + * type: the mapped type distributes over every known pattern and keeps only the arms the string satisfies. + */ +export type MatchApiPath = { + [Pattern in ParameterisedApiPath]: Path extends TemplatedApiPath ? Pattern : never; +}[ParameterisedApiPath]; + +/** The parsed response for a concrete parameterised path. */ +export type ParameterisedApiResponse = z.infer<(typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiPath]>; diff --git a/packages/loopover-contract/src/cli-config.ts b/packages/loopover-contract/src/cli-config.ts index 6142c9ea98..82a7677436 100644 --- a/packages/loopover-contract/src/cli-config.ts +++ b/packages/loopover-contract/src/cli-config.ts @@ -40,13 +40,27 @@ export const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; export type LoopoverConfigProfile = { apiUrl?: unknown; - session?: { token?: unknown } | null | undefined; + /** #9773: stamped by `loopover-mcp login` and preserved across re-logins. It was absent from this type + * while the CLI read and wrote it, so nothing checked the field even existed. */ + createdAt?: unknown; + session?: LoopoverConfigSession | null | undefined; }; +export type LoopoverConfigSession = { token?: unknown; createdAt?: unknown; login?: unknown }; + export type LoopoverConfig = { activeProfile?: unknown; profiles?: Record; apiUrl?: unknown; + /** + * #9773: the pre-profile session and the telemetry opt-in, both still read and written by the CLI. + * + * `session` is the LEGACY single-session shape from before profiles existed -- `loopover-mcp login` on + * the default profile still writes it so an older CLI reading the same file keeps working, which is + * exactly why it cannot be dropped from the type. + */ + session?: LoopoverConfigSession | null | undefined; + telemetryEnabled?: unknown; }; /** diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 9681827a6a..cf5e0ab96a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -6,7 +6,14 @@ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { CLI_RESPONSE_SCHEMAS, type ApiResponse, type ValidatedApiPath } from "@loopover/contract/api-schemas"; +import { + CLI_RESPONSE_SCHEMAS, + type ApiResponse, + type MatchApiPath, + type ParameterisedApiResponse, + type ValidatedApiPath, +} from "@loopover/contract/api-schemas"; +import type { LoopoverConfig } from "@loopover/contract/cli-config"; import { CLIENT_HOSTS, CLIENT_HOST_SPEC, @@ -355,6 +362,28 @@ const CLI_FLAG_SPEC = { } as const satisfies Record; export { CLI_FLAG_SPEC }; + +/** The flags CLI_FLAG_SPEC marks as repeatable -- each accumulates into an array. */ +type RepeatableFlag = { [K in keyof typeof CLI_FLAG_SPEC]: (typeof CLI_FLAG_SPEC)[K] extends "repeatable" ? K : never }[keyof typeof CLI_FLAG_SPEC]; +/** The flags CLI_FLAG_SPEC marks as boolean -- present means true unless `=false`/`=` disables it. */ +type BooleanFlag = { [K in keyof typeof CLI_FLAG_SPEC]: (typeof CLI_FLAG_SPEC)[K] extends "boolean" ? K : never }[keyof typeof CLI_FLAG_SPEC]; + +/** + * What `parseOptions` produces (#9773). + * + * The known flags carry the type CLI_FLAG_SPEC declares for them, so a handler that reads `options.issue` + * as a string rather than an array is a compile error. The index signature is not a loophole -- the parser + * genuinely accepts any `--flag`, building the key from the argument itself, so a closed record would be a + * lie. What it is NOT is `any`: an unlisted flag is `string | boolean`, which still has to be narrowed + * before it can be used as either. + */ +export type CliOptions = { + [K in RepeatableFlag]?: string[]; +} & { + [K in BooleanFlag]?: boolean; +} & { + [flag: string]: string | boolean | string[] | undefined; +}; const REPEATABLE_FLAGS = new Set(Object.entries(CLI_FLAG_SPEC).filter(([, kind]) => kind === "repeatable").map(([flag]) => flag)); const BOOLEAN_FLAGS = new Set(Object.entries(CLI_FLAG_SPEC).filter(([, kind]) => kind === "boolean").map(([flag]) => flag)); const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; @@ -549,7 +578,7 @@ type ClaimLedgerModule = { }; }; -async function resolveLedgerClaimStatus(repoFullName: any, issueNumber: any) { +async function resolveLedgerClaimStatus(repoFullName: string | undefined, issueNumber: number | undefined) { if (!repoFullName || !issueNumber) return null; let claimLedgerModule: ClaimLedgerModule; try { @@ -1285,7 +1314,7 @@ registerStdioTool( ...(input.body !== undefined ? { body: input.body } : {}), ...(input.labels !== undefined ? { labels: input.labels } : {}), ...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}), - ...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path: any) => ({ path })) } : {}), + ...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path: string) => ({ path })) } : {}), }; const result = await apiPost("/v1/local/branch-analysis", body); return toolResult(`LoopOver predicted gate for ${input.owner}/${input.repo}.`, result.predictedGate); @@ -1304,7 +1333,7 @@ registerStdioTool( ...(input.body !== undefined ? { body: input.body } : {}), ...(input.labels !== undefined ? { labels: input.labels } : {}), ...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}), - ...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path: any) => ({ path })) } : {}), + ...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path: string) => ({ path })) } : {}), }; const result = await apiPost("/v1/local/branch-analysis", body); // #9587 declared predictedGate with a real schema, so this is typed straight off the response now. @@ -1823,7 +1852,7 @@ registerStdioTool( // from there) -- no new HTTP paths, and no behaviour the CLI doesn't already have. /** `/v1/repos/:owner/:repo` for a tool's owner+repo input, matching maintainCli's own repoBase. */ -function toolRepoBase(owner: any, repo: any) { +function toolRepoBase(owner: string, repo: string) { return `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; } @@ -2692,14 +2721,19 @@ function printMaintainHelp() { // #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer // settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally. -export async function maintainCli(args: any) { +export async function maintainCli(args: readonly string[]) { const subcommand = args[0]; if (!subcommand || subcommand === "--help" || subcommand === "help") return printMaintainHelp(); const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined; const options = parseOptions(args.slice(1)); const repoFullName = options.repo; - if (!repoFullName || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo."); - const [owner, repo] = repoFullName.split("/", 2); + // #9773: `typeof`, not truthiness. A bare `--repo` with no value parses to `true`, which passed the old + // `!repoFullName` guard and then threw `repoFullName.includes is not a function` -- an unhandled + // TypeError where the usage error was intended. The `any` on this parameter is what hid it. + if (typeof repoFullName !== "string" || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo."); + // The defaults never fire: the guard proved the string contains a "/". They exist because + // noUncheckedIndexedAccess types a destructured split as possibly-undefined. + const [owner = "", repo = ""] = repoFullName.split("/", 2); const repoBase = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const queueBase = `${repoBase}/agent/pending-actions`; const emit = (payload: unknown, line: string) => { @@ -3018,7 +3052,7 @@ function cliCommandHandlers(): Record unknown> { }; } -async function runCli(args: any) { +async function runCli(args: readonly string[]) { const command = args[0]; if (command === undefined || command === "--help" || command === "help") return printHelp(); // Aliases kept from the old dispatch chain: they are spellings, not commands, so they live here @@ -3044,8 +3078,8 @@ async function analyzeOrPreflightCli(command: "analyze-branch" | "preflight", re const result = await analyzeCurrentBranch({ login: contributorLogin, cwd: options.cwd, - repoFullName: options.repo, - baseRef: options.base, + repoFullName: optionText(options.repo), + baseRef: optionText(options.base), title: options.title, body: options.body, labels: options.label, @@ -3077,7 +3111,7 @@ async function analyzeOrPreflightCli(command: "analyze-branch" | "preflight", re // Render the report-shaped branch analysis (next actions, plus score blockers for analyze-branch) as // aligned monospace tables when `--format table` is passed. Default and `--json` output are untouched. -function writeBranchAnalysisTable(result: any, command: any) { +function writeBranchAnalysisTable(result: any, command: string) { const analysis = result.analysis; const actionRows = (analysis.nextActions ?? []).map((action: any) => ({ action: action.actionKind ?? "—", @@ -3109,19 +3143,19 @@ function printReviewPrHelp() { ); } -async function reviewPrCli(options: any) { +async function reviewPrCli(options: CliOptions) { if (options.help === true) return printReviewPrHelp(); const contributorLogin = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!contributorLogin) throw new Error("Pass --login or set LOOPOVER_LOGIN."); let prBody = options.body; - if (options.bodyFile) prBody = readCliTextFile(options.bodyFile, "Body"); + if (options.bodyFile) prBody = readCliTextFile(optionText(options.bodyFile) ?? "", "Body"); const commitMessages = Array.isArray(options.commit) ? options.commit : options.commit ? [options.commit] : undefined; const linkedIssue = parsePositiveIntegerOption(options.linkedIssue, "--linked-issue"); const payload = await reviewLocalPr({ login: contributorLogin, cwd: options.cwd, - repoFullName: options.repo, - baseRef: options.base, + repoFullName: optionText(options.repo), + baseRef: optionText(options.base), title: options.title, body: prBody, labels: options.label, @@ -3146,7 +3180,7 @@ async function reviewPrCli(options: any) { // special file (FIFO, device) can be swapped in between the two calls, letting the earlier // isFile()/size validation apply to a different, unvalidated file than the one actually read. // O_NOFOLLOW makes a symlinked path fail to open outright instead of silently following it. -function readCliTextFile(path: any, label: any) { +function readCliTextFile(path: string, label: any) { let fd; try { fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); @@ -3190,13 +3224,13 @@ function printLintPrTextHelp() { ); } -async function lintPrTextCli(args: any) { +async function lintPrTextCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printLintPrTextHelp(); const options = parseOptions(args); const commitMessages = Array.isArray(options.commit) ? options.commit : options.commit ? [options.commit] : undefined; let prBody = options.body; if (options.bodyFile) { - prBody = readCliTextFile(options.bodyFile, "Body"); + prBody = readCliTextFile(optionText(options.bodyFile) ?? "", "Body"); } const linkedIssue = parsePositiveIntegerOption(options.linkedIssue, "--linked-issue"); const payload = await apiPost("/v1/lint/pr-text", { @@ -3244,11 +3278,11 @@ function printValidateConfigHelp() { ); } -async function validateConfigCli(args: any) { +async function validateConfigCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printValidateConfigHelp(); const options = parseOptions(args); if (!options.file) throw new Error("Pass --file to the manifest to validate."); - const content = readCliTextFile(options.file, "Manifest"); + const content = readCliTextFile(optionText(options.file) ?? "", "Manifest"); const source = options.source; if (source !== undefined && !["repo_file", "api_record", "none"].includes(String(source))) { throw new Error("--source must be one of: repo_file, api_record, none"); @@ -3301,11 +3335,11 @@ function parseChangedFileSpec(raw: any) { return entry; } -async function slopRiskCli(args: any) { +async function slopRiskCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printSlopRiskHelp(); const options = parseOptions(args); let description = options.description ?? options.body; - const descriptionFile = options.descriptionFile ?? options.bodyFile; + const descriptionFile = optionText(options.descriptionFile) ?? optionText(options.bodyFile); if (descriptionFile) { description = readCliTextFile(descriptionFile, "Description"); } @@ -3342,7 +3376,7 @@ function printImprovementPotentialHelp() { ); } -async function improvementPotentialCli(args: any) { +async function improvementPotentialCli(args: readonly string[]) { // #6748: shell CLI mirror of loopover_check_improvement_potential, matching slopRiskCli's HTTP-proxy pattern // (the pure builder lives in src/signals/improvement.ts, not yet an @loopover/engine export for in-process use). if (!args.length || args[0] === "--help" || args[0] === "help") return printImprovementPotentialHelp(); @@ -3387,12 +3421,12 @@ function printIssueSlopHelp() { ); } -async function issueSlopCli(args: any) { +async function issueSlopCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printIssueSlopHelp(); const options = parseOptions(args); let body = normalizeOptionalStringOption(options.body); if (options.bodyFile) { - body = readCliTextFile(options.bodyFile, "Body"); + body = readCliTextFile(optionText(options.bodyFile) ?? "", "Body"); } const title = normalizeOptionalStringOption(options.title); const payload = await apiPost("/v1/lint/issue-slop", { @@ -3443,9 +3477,9 @@ function printContributorProfileHelp() { // because the top-level `profile` command already manages MCP client profiles. // #7760: exported (like maintainCli) so an in-process test can drive it directly -- the subprocess CLI harness // v8 can't instrument, so the shared getContributorProfile call below is graded through this in-process entry. -export async function contributorProfileCli(options: any) { +export async function contributorProfileCli(options: CliOptions) { if (options.help === true) return printContributorProfileHelp(); - const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); // #7760: shared with the loopover_get_contributor_profile stdio tool so the endpoint path lives in one place. const payload = await getContributorProfile(login); @@ -3454,15 +3488,16 @@ export async function contributorProfileCli(options: any) { `); return; } + // #9773: a `payload.summary` line used to follow this one. ContributorProfile has never carried a + // summary -- not the github-cache shape, not the gittensor one -- so `if (payload.summary)` was never + // true and the line could not print. Typing the response is what made that visible. process.stdout.write(`LoopOver contributor profile for ${login}. -`); - if (payload.summary) process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary)} `); } -async function decisionPackCli(options: any) { +async function decisionPackCli(options: CliOptions) { if (options.help === true) return printDecisionPackHelp(); - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); const payload = await getDecisionPackWithCache(login); if (options.json) { @@ -3490,9 +3525,9 @@ function printMonitorOpenPrsHelp() { ); } -async function monitorOpenPrsCli(options: any) { +async function monitorOpenPrsCli(options: CliOptions) { if (options.help === true) return printMonitorOpenPrsHelp(); - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); const payload = await getOpenPrMonitor(login); if (options.json) { @@ -3524,9 +3559,9 @@ function printPrOutcomesHelp() { ); } -async function prOutcomesCli(options: any) { +async function prOutcomesCli(options: CliOptions) { if (options.help === true) return printPrOutcomesHelp(); - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); const limitRaw = options.limit; let limit; @@ -3564,9 +3599,9 @@ function printExplainReviewRiskHelp() { ); } -async function explainReviewRiskCli(options: any) { +async function explainReviewRiskCli(options: CliOptions) { if (options.help === true) return printExplainReviewRiskHelp(); - const repoFullName = options.repoFullName ?? options.repo; + const repoFullName = optionText(options.repoFullName) ?? optionText(options.repo); if (!repoFullName || !String(repoFullName).includes("/")) throw new Error("Pass --repo owner/repo or --repoFullName owner/repo."); if (!options.title) throw new Error("Pass --title ."); const contributorLogin = options.login ?? options.contributorLogin; @@ -3616,9 +3651,9 @@ function printNotificationsHelp() { // #6745: CLI mirror of loopover_list_notifications. Login resolves from --login / the active session / // LOOPOVER_LOGIN / GITHUB_LOGIN, like the sibling contributor commands. -async function notificationsCli(options: any) { +async function notificationsCli(options: CliOptions) { if (options.help === true) return printNotificationsHelp(); - const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); const payload = await getNotifications(login); if (options.json) { @@ -3636,7 +3671,7 @@ async function notificationsCli(options: any) { // #7763: shared REST dispatch for a contributor's issue-watch subscriptions, reused by the `watch` CLI and the // loopover_watch_issues stdio tool so there is no duplicated HTTP logic. action maps list=GET, watch=POST, // unwatch=DELETE on the /v1/contributors/:login/watches route family (the same routes the CLI already hit). -function watchIssuesRequest(login: any, action: any, repoFullName?: any, labels?: any) { +function watchIssuesRequest(login: string, action: any, repoFullName?: any, labels?: any) { const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; if (action === "watch") return apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) }); if (action === "unwatch") return apiDelete(base, { repoFullName }); @@ -3647,12 +3682,12 @@ function watchIssuesRequest(login: any, action: any, repoFullName?: any, labels? // route family. The MCP tool's action enum maps to subcommands here: list=GET, add=POST, remove=DELETE. // Exported (like maintainCli, #7764) so an in-process test can cover the shared watchIssuesRequest call sites // that a subprocess spawn can't instrument (#7763). -export async function watchCli(args: any) { +export async function watchCli(args: readonly string[]) { const subcommand = args[0]; if (!subcommand || subcommand === "--help" || subcommand === "help") return printWatchHelp(); const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined; const options = parseOptions(args.slice(1)); - const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); // The API chooses `changed` / repo / label text, so the plain-text path is sanitized (#6261); `login` is the // user's own value. @@ -3727,9 +3762,9 @@ function printNotificationsReadHelp() { // #6745: CLI mirror of loopover_mark_notifications_read. Repeated --id flags collect into an ids array; omitting // them marks every delivered notification read (mirrors the route's absent-body behavior). -async function notificationsReadCli(options: any) { +async function notificationsReadCli(options: CliOptions) { if (options.help === true) return printNotificationsReadHelp(); - const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); const ids = Array.isArray(options.id) ? options.id : options.id ? [options.id] : undefined; const payload = await postMarkNotificationsRead(login, ids); @@ -3753,13 +3788,18 @@ function printRepoDecisionHelp() { ); } -async function repoDecisionCli(options: any) { +async function repoDecisionCli(options: CliOptions) { if (options.help === true) return printRepoDecisionHelp(); - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); const repoFullName = options.repo; - if (!repoFullName || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo."); - const [owner, repo] = repoFullName.split("/", 2); + // #9773: `typeof`, not truthiness. A bare `--repo` with no value parses to `true`, which passed the old + // `!repoFullName` guard and then threw `repoFullName.includes is not a function` -- an unhandled + // TypeError where the usage error was intended. The `any` on this parameter is what hid it. + if (typeof repoFullName !== "string" || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo."); + // The defaults never fire: the guard proved the string contains a "/". They exist because + // noUncheckedIndexedAccess types a destructured split as possibly-undefined. + const [owner = "", repo = ""] = repoFullName.split("/", 2); const payload = await getRepoDecisionWithCache(login, owner, repo); if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -3773,7 +3813,7 @@ async function repoDecisionCli(options: any) { if (payload.cache?.rerunGuidance) process.stdout.write(`Rerun when: ${sanitizePlainTextTerminalOutput(payload.cache.rerunGuidance)}\n`); } -function runCacheCli(args: any) { +function runCacheCli(args: readonly string[]) { const subcommand = args[0] ?? "help"; if (subcommand === "--help" || subcommand === "help") return printCacheHelp(); const options = parseOptions(args.slice(1)); @@ -3799,7 +3839,7 @@ function runCacheCli(args: any) { throw new Error(`Unknown cache command: ${subcommand}`); } -async function runAgentCli(args: any) { +async function runAgentCli(args: readonly string[]) { const subcommand = args[0] ?? "help"; if (subcommand === "--help" || subcommand === "help") return printAgentHelp(); const options = parseOptions(args.slice(1)); @@ -3808,7 +3848,7 @@ async function runAgentCli(args: any) { // /v1/agent/runs request shape. `objective` and `actorLogin` are non-optional in agentRunShape // (src/mcp/server.ts), so enforce both here, resolving --login exactly as plan/packet do. surface is "cli" // (not the stdio tool's "mcp") so the two entry points stay distinguishable server-side, per the issue. - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); if (!options.objective || options.objective === true) throw new Error('Pass --objective "..." to describe the run.'); const payload = await apiPost("/v1/agent/runs", { @@ -3816,7 +3856,7 @@ async function runAgentCli(args: any) { actorLogin: login, surface: "cli", target: stripUndefined({ - repoFullName: options.repo, + repoFullName: optionText(options.repo), pullNumber: optionalInteger(options.pull), issueNumber: optionalInteger(Array.isArray(options.issue) ? options.issue[0] : options.issue), }), @@ -3824,32 +3864,32 @@ async function runAgentCli(args: any) { return outputAgentPayload(payload, options, `Queued LoopOver base-agent run for ${login}.`); } if (subcommand === "plan") { - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); - const payload = await apiPost("/v1/agent/plan-next-work", stripUndefined({ login, repoFullName: options.repo, objective: options.objective, surface: "mcp" })); + const payload = await apiPost("/v1/agent/plan-next-work", stripUndefined({ login, repoFullName: optionText(options.repo), objective: options.objective, surface: "mcp" })); return outputAgentPayload(payload, options, `LoopOver agent plan: ${payload.summary ?? payload.run?.status ?? "ready"}`); } if (subcommand === "status") { - const runId = args[1] && !args[1].startsWith("--") ? args[1] : options.runId; + const runId = args[1] && !args[1].startsWith("--") ? args[1] : optionText(options.runId); if (!runId) throw new Error("Usage: loopover-mcp agent status "); const payload = await apiGet(`/v1/agent/runs/${encodeURIComponent(runId)}`); return outputAgentPayload(payload, options, `LoopOver agent run ${runId}: ${payload.run?.status ?? "unknown"}`); } if (subcommand === "explain") { - const runId = args[1] && !args[1].startsWith("--") ? args[1] : options.runId; + const runId = args[1] && !args[1].startsWith("--") ? args[1] : optionText(options.runId); if (!runId) throw new Error("Usage: loopover-mcp agent explain "); const payload = await apiGet(`/v1/agent/runs/${encodeURIComponent(runId)}`); const topAction = payload.actions?.[0] ?? null; return outputAgentPayload({ ...payload, topAction }, options, topAction ? `Top action: ${topAction.recommendation}` : "No top action is available yet."); } if (subcommand === "packet") { - const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + const login = optionText(options.login) ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set LOOPOVER_LOGIN."); const payload = await agentPreparePrPacket({ login, cwd: options.cwd, - repoFullName: options.repo, - baseRef: options.base, + repoFullName: optionText(options.repo), + baseRef: optionText(options.base), title: options.title, body: options.body, labels: options.label, @@ -3882,6 +3922,9 @@ export { runCli }; // The agent-run payload fields this printer reads. The /v1/agent/* runs endpoints' 200s are inline in the // document (#9531), so this is the CLI's own contract with what it prints -- structural, not `any`. type AgentRunOutputPayload = { + /** #9773: added by `agent explain` before printing -- a LOCAL decoration, never a server field. The type + * did not allow for it, which only compiled while the payload it decorated was `any`. */ + topAction?: AgentRunOutputAction | null | undefined; prPacket?: { markdown?: unknown } | null | undefined; actions?: Array | undefined; nextActions?: Array | undefined; @@ -3928,7 +3971,7 @@ function outputAgentPayload(payload: AgentRunOutputPayload, options: { json?: bo } } -function writeBranchAnalysisCli(result: any, command: any) { +function writeBranchAnalysisCli(result: any, command: string) { const analysis = result.analysis; const intelligence = command === "preflight" ? publicSafeWorkspaceIntelligence(analysis.workspaceIntelligence) : analysis.workspaceIntelligence; process.stdout.write(`${analysis.summary}\n`); @@ -4011,7 +4054,7 @@ function isUnsafePublicPacketText(value: any) { return /\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Z]:[\\/]Users[\\/]/i.test(value); } -function printVersion(options: any) { +function printVersion(options: CliOptions) { const payload = { name: packageName, version: packageVersion, apiVersion: currentApiVersion, node: process.version }; if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -4020,7 +4063,7 @@ function printVersion(options: any) { process.stdout.write(`${packageName}/${packageVersion} (api ${currentApiVersion}, node ${process.version})\n`); } -function toolsCommand(args: any) { +function toolsCommand(args: readonly string[]) { const subcommand = args[0]; if (subcommand === "search") return toolsSearchCommand(args.slice(1)); const options = parseOptions(args); @@ -4056,7 +4099,7 @@ function toolsCommand(args: any) { // surfaces loopover_check_before_start even though "duplicate" is only in its description. Reuses // this CLI's existing levenshteinDistance for typo tolerance rather than pulling in a fuzzy-match // dependency. -function toolsSearchCommand(args: any) { +function toolsSearchCommand(args: readonly string[]) { const options = parseOptions(args); const query = args.find((arg: any) => !arg.startsWith("--")); if (!query) throw new Error("Usage: loopover-mcp tools search [--json]"); @@ -4083,7 +4126,7 @@ function printToolRows(tools: any) { // Rank registered tools by how well they match the query, best first. A substring hit on the name beats // a substring hit on the description, which beats a typo-tolerant (Levenshtein) hit on any name/description // token; tools that match none of these are dropped. Ties break alphabetically for a stable listing. -function searchTools(query: any) { +function searchTools(query: string) { const needle = query.toLowerCase(); const scored = []; for (const { name, description } of STDIO_TOOL_DESCRIPTORS) { @@ -4094,7 +4137,7 @@ function searchTools(query: any) { return scored.map(({ name, description }) => ({ name, description })); } -function scoreToolMatch(needle: any, name: any, description: any) { +function scoreToolMatch(needle: any, name: string, description: any) { if (name.includes(needle)) return 0; if (description.includes(needle)) return 1; // Typo tolerance: compare the query to each name/description token, allowing a small edit distance that @@ -4109,7 +4152,7 @@ function scoreToolMatch(needle: any, name: any, description: any) { return best <= budget ? 2 + best : null; } -function completionCommand(args: any) { +function completionCommand(args: readonly string[]) { const shell = args[0] && !args[0].startsWith("--") ? args[0] : undefined; const options = parseOptions(args.filter((arg: any) => arg.startsWith("--"))); if (!shell) throw new Error(`Usage: loopover-mcp completion <${COMPLETION_SHELLS.join("|")}> [--json]`); @@ -4122,7 +4165,7 @@ function completionCommand(args: any) { process.stdout.write(`${script}\n`); } -function buildCompletionScript(shell: any) { +function buildCompletionScript(shell: string) { const topLevel = [...Object.keys(CLI_COMMAND_SPEC), "help"]; const withSubcommands = Object.entries(CLI_COMMAND_SPEC) .map(([command, entry]) => [command, entry.subcommands] as const) @@ -4212,7 +4255,7 @@ _loopover_mcp "$@"`; function buildFishCompletion(topLevel: any, withSubcommands: any) { const topLevelLines = topLevel - .map((command: any) => `complete -c loopover-mcp -n __fish_use_subcommand -a ${command} -d 'loopover-mcp command'`) + .map((command: string) => `complete -c loopover-mcp -n __fish_use_subcommand -a ${command} -d 'loopover-mcp command'`) .join("\n"); const subcommandLines = withSubcommands .map(([command, subcommands]: any) => `complete -c loopover-mcp -n '__fish_seen_subcommand_from ${command}' -a '${subcommands.join(" ")}'`) @@ -4224,7 +4267,7 @@ ${subcommandLines}`; } function buildPowershellCompletion(topLevel: any, withSubcommands: any) { - const commandList = topLevel.map((command: any) => `'${command}'`).join(", "); + const commandList = topLevel.map((command: string) => `'${command}'`).join(", "); const subcommandEntries = withSubcommands .map(([command, subcommands]: any) => ` '${command}' = @(${subcommands.map((subcommand: any) => `'${subcommand}'`).join(", ")})`) .join("\n"); @@ -4306,8 +4349,32 @@ function printProfileHelp() { process.stdout.write(printableUsage("profile")); } -function parseOptions(args: any) { - const options: any = {}; +/** + * An option's value as text (#9773). + * + * A BARE flag -- `--login` with no value, or followed by another `--flag` -- parses to `true`. Reading that + * as the literal string "true" is how `loopover-mcp decision-pack --login` came to request a contributor + * NAMED "true" and report them not found, rather than telling the user they forgot the value. Absent is the + * honest answer, and every caller here already handles absent. + */ +function optionText(value: string | boolean | string[] | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** + * What a repeatable flag has accumulated so far. + * + * #9773: was `options[key] ?? []`, which spread whatever was there. A BARE repeatable flag (`--issue` with + * no value, or followed by another `--flag`) is stored as `true` by the branch below, so + * `loopover-mcp preflight-pr --issue --issue 5` spread `true` and threw "true is not iterable". Anything + * that is not already a list starts a fresh one -- the only sane reading of a bare flag carrying no value. + */ +function asRepeated(current: string | boolean | string[] | undefined): string[] { + return Array.isArray(current) ? current : []; +} + +function parseOptions(args: readonly string[]): CliOptions { + const options: CliOptions = {}; // Both sets come from CLI_FLAG_SPEC (#9521) -- see the kind meanings there. const repeatable = REPEATABLE_FLAGS; const booleanFlags = BOOLEAN_FLAGS; @@ -4332,7 +4399,7 @@ function parseOptions(args: any) { if (equals !== -1) { const inlineKey = camel(arg.slice(2, equals)); const inlineValue = arg.slice(equals + 1); - if (repeatable.has(inlineKey)) options[inlineKey] = [...(options[inlineKey] ?? []), inlineValue]; + if (repeatable.has(inlineKey)) options[inlineKey] = [...asRepeated(options[inlineKey]), inlineValue]; // Only the exact strings "false" and "" disable a boolean flag (`--json=false`, // `--exit-code=false`); any other value (`--json=true`, bare `--json` elsewhere) keeps the // flag's previous enabling behavior, so ONLY the `=false` form changes meaning. @@ -4347,7 +4414,7 @@ function parseOptions(args: any) { continue; } index += 1; - if (repeatable.has(key)) options[key] = [...(options[key] ?? []), value]; + if (repeatable.has(key)) options[key] = [...asRepeated(options[key]), value]; else options[key] = value; } return options; @@ -4357,7 +4424,7 @@ function parseOptions(args: any) { // array element per line (for piping into jq/log processors); `--json` (or `--format json`) keeps the // existing pretty object. Returns true when it emitted a machine-readable format, so the caller skips the // human view. Each record ends in "\n" and Node flushes stdout on exit, so piped output is not truncated. -function emitList(options: any, items: any, pretty: any) { +function emitList(options: CliOptions, items: any, pretty: any) { if (options.format === "ndjson") { for (const item of items) process.stdout.write(`${JSON.stringify(item)}\n`); return true; @@ -4369,7 +4436,7 @@ function emitList(options: any, items: any, pretty: any) { return false; } -async function login(options: any) { +async function login(options: CliOptions) { const profileName = selectedProfileName(options); const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN; const session = githubToken ? await apiFetch("/v1/auth/github/session", { method: "POST", body: JSON.stringify({ githubToken }) }, { auth: false }) : await loginWithDeviceFlow(); @@ -4416,7 +4483,7 @@ async function loginWithDeviceFlow() { throw new Error("GitHub OAuth device flow expired."); } -async function logout(options: any) { +async function logout(options: CliOptions) { const profileName = selectedProfileName(options); const all = options.all === true; const envToken = getEnvApiToken(); @@ -4444,7 +4511,7 @@ async function logout(options: any) { // self-hoster must explicitly enable it before anything is measured. The opt-in is a single top-level // `telemetryEnabled` flag persisted in the same config file `login` uses, so the choice survives across // CLI invocations; `status`, `doctor`, and `config` all report the current state. -function telemetryCommand(args: any) { +function telemetryCommand(args: readonly string[]) { const subcommand = args[0] ?? "status"; const options = parseOptions(args.slice(1)); if (subcommand === "--help" || subcommand === "help") return printTelemetryHelp(); @@ -4481,7 +4548,7 @@ flag in the same config file \`loopover-mcp login\` uses, so the choice survives `); } -function profileCommand(args: any) { +function profileCommand(args: readonly string[]) { const subcommand = args[0] ?? "list"; const options = parseOptions(args.slice(1)); if (subcommand === "--help" || subcommand === "help") return printProfileHelp(); @@ -4532,13 +4599,13 @@ function profileCommand(args: any) { throw new Error(`Unknown profile command: ${subcommand}`); } -async function whoami(options: any) { +async function whoami(options: CliOptions) { const payload = { ...(await apiGet("/v1/auth/session")), profile: activeProfileName }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else process.stdout.write(activeProfileName === defaultProfileName ? `${payload.login}\n` : `${payload.login} (profile ${activeProfileName})\n`); } -async function status(options: any) { +async function status(options: CliOptions) { let auth: any = { status: getApiToken() ? "token_configured" : "unauthenticated" }; let health = null; if (getApiToken()) { @@ -4600,7 +4667,7 @@ async function status(options: any) { } } -async function changelog(options: any) { +async function changelog(options: CliOptions) { const text = existsSync(changelogPath) ? readFileSync(changelogPath, "utf8") : "# Changelog\n\nNo packaged changelog was found.\n"; const payload = { package: { @@ -4613,9 +4680,9 @@ async function changelog(options: any) { else process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); } -async function doctor(options: any) { +async function doctor(options: CliOptions) { const checks: any[] = []; - const add = (name: any, statusValue: any, detail: any, remediation?: any) => + const add = (name: string, statusValue: any, detail: any, remediation?: any) => checks.push( stripUndefined({ name, @@ -4706,9 +4773,9 @@ async function doctor(options: any) { try { const metadata = collectLocalBranchMetadata({ - cwd: options.cwd ?? process.cwd(), - baseRef: options.base, - repoFullName: options.repo, + cwd: optionText(options.cwd) ?? process.cwd(), + baseRef: optionText(options.base), + repoFullName: optionText(options.repo), login: options.login ?? activeProfile.session?.login ?? "local", }); repoFullName = metadata.repoFullName ?? repoFullName; @@ -4904,7 +4971,7 @@ function shellArg(value: any) { return `'${text.replace(/'/g, `'"'"'`)}'`; } -function initClient(options: any) { +function initClient(options: CliOptions) { const client = String(options.print ?? options.client ?? "").toLowerCase() as ClientHost; if (!client) throw new Error(`Pass --print with one of: ${CLIENT_HOSTS.join(", ")}.`); if (!CLIENT_HOSTS.includes(client)) throw new Error(`Unsupported client: ${client}. Use ${CLIENT_HOSTS.join(", ")}.`); @@ -4914,7 +4981,7 @@ function initClient(options: any) { if (!CONNECTION_MODES.includes(mode)) throw new Error(`Unsupported mode: ${mode}. Use ${CONNECTION_MODES.join(", ")}.`); if (!supportsConnectionMode(client, mode)) throw new Error(`${CLIENT_HOST_SPEC[client].title} cannot connect over the ${CONNECTION_MODE_SPEC[mode].title} mode.`); const modeSpec = CONNECTION_MODE_SPEC[mode]; - const command = options.command ?? modeSpec.command ?? "loopover-mcp"; + const command = optionText(options.command) ?? modeSpec.command ?? "loopover-mcp"; const snippet = clientConfigSnippet(client, mode, { command }); const agentProfile = resolveAgentProfile(options.agentProfile); const remoteNote = modeSpec.transport === "http" ? CLIENT_HOST_SPEC[client].remoteNote : undefined; @@ -4957,10 +5024,10 @@ function formatAgentProfile(profile: any) { `Purpose: ${profile.purpose}`, "", "Recommended MCP prompts:", - ...profile.recommendedPrompts.map((name: any) => `- ${name}`), + ...profile.recommendedPrompts.map((name: string) => `- ${name}`), "", "Recommended MCP tools:", - ...profile.recommendedTools.map((name: any) => `- ${name}`), + ...profile.recommendedTools.map((name: string) => `- ${name}`), ...(profile.drivingLoop ? ["", "Driving loop (plan → implement → push, gate-throttled):", ...profile.drivingLoop.map((step: any, index: any) => `${index + 1}. ${step}`)] : []), "", "Safety boundaries:", @@ -4981,20 +5048,20 @@ function getEnvApiToken() { return process.env.LOOPOVER_API_TOKEN ?? process.env.LOOPOVER_MCP_TOKEN ?? process.env.LOOPOVER_TOKEN; } -function selectedProfileName(options: any = {}) { +function selectedProfileName(options: CliOptions = {}) { return normalizeProfileName(options.profile ?? activeProfileName); } -function configuredProfileToken(profileName: any, currentConfig = config) { +function configuredProfileToken(profileName: string, currentConfig = config) { return currentConfig.profiles?.[profileName]?.session?.token; } -function profileSessions(currentConfig: any = config) { +function profileSessions(currentConfig: LoopoverConfig = config) { return Object.entries(currentConfig.profiles ?? {}) .flatMap(([name, profile]: any) => (profile?.session?.token ? [{ name, session: profile.session }] : [])); } -function profilePublicState(profileName: any, currentConfig = config) { +function profilePublicState(profileName: string, currentConfig = config) { const profile = currentConfig.profiles?.[profileName]; const hasEnvToken = Boolean(getEnvApiToken()); return { @@ -5014,7 +5081,7 @@ function profileList(currentConfig = config) { return [...names].sort((left, right) => (left === currentConfig.activeProfile ? -1 : right === currentConfig.activeProfile ? 1 : left.localeCompare(right))).map((name) => profilePublicState(name, currentConfig)); } -function selectProfileName(currentConfig: any, requestedName: any) { +function selectProfileName(currentConfig: LoopoverConfig, requestedName: any) { const requested = requestedName ? normalizeProfileName(requestedName) : undefined; if (requested) return requested; const configured = currentConfig?.activeProfile ? normalizeProfileName(currentConfig.activeProfile) : defaultProfileName; @@ -5067,7 +5134,7 @@ function telemetryState(currentConfig = config) { // local absolute paths or token values. Distinct from `status` (health/version), `doctor` // (diagnostic checks), and `whoami` (session identity): this answers "what config is in effect // and which source supplied it?". -function configCommand(options: any) { +function configCommand(options: CliOptions) { const payload = { apiUrl, apiUrlSource: resolvedApiUrlSource(), @@ -5105,7 +5172,7 @@ function normalizeProfileName(value: any) { return name; } -function cliOptionValue(args: any, optionName: any) { +function cliOptionValue(args: readonly string[], optionName: any) { const dashed = `--${optionName.replace(/[A-Z]/g, (letter: any) => `-${letter.toLowerCase()}`)}`; for (let index = 0; index < args.length; index += 1) { const value = args[index]; @@ -5118,7 +5185,7 @@ function cliOptionValue(args: any, optionName: any) { return undefined; } -function upsertProfile(currentConfig: any, profileName: any, patch: any) { +function upsertProfile(currentConfig: LoopoverConfig, profileName: string, patch: any) { const now = new Date().toISOString(); const existing = currentConfig.profiles?.[profileName] ?? {}; const profiles = { @@ -5134,17 +5201,17 @@ function upsertProfile(currentConfig: any, profileName: any, patch: any) { return normalizeConfig({ ...currentConfig, apiUrl: patch.apiUrl ?? currentConfig.apiUrl, activeProfile: profileName, profiles }); } -function ensureProfile(currentConfig: any, profileName: any, options: any = {}) { +function ensureProfile(currentConfig: LoopoverConfig, profileName: string, options: CliOptions = {}) { const existing = currentConfig.profiles?.[profileName]; const nextConfig = existing ? currentConfig : upsertProfile(currentConfig, profileName, {}); return options.activate ? setActiveProfile(nextConfig, profileName) : nextConfig; } -function setActiveProfile(currentConfig: any, profileName: any) { +function setActiveProfile(currentConfig: LoopoverConfig, profileName: string) { return normalizeConfig({ ...currentConfig, activeProfile: profileName }); } -function clearProfileSession(currentConfig: any, profileName: any) { +function clearProfileSession(currentConfig: LoopoverConfig, profileName: string) { const existing = currentConfig.profiles?.[profileName]; if (!existing) return currentConfig; const profiles = { @@ -5154,14 +5221,14 @@ function clearProfileSession(currentConfig: any, profileName: any) { return normalizeConfig({ ...currentConfig, profiles }); } -function clearAllProfileSessions(currentConfig: any) { +function clearAllProfileSessions(currentConfig: LoopoverConfig) { const profiles = Object.fromEntries( Object.entries(currentConfig.profiles ?? {}).map(([name, profile]: any) => [name, stripUndefined({ ...profile, session: undefined, updatedAt: new Date().toISOString() })]), ); return normalizeConfig({ ...currentConfig, profiles }); } -function removeProfile(currentConfig: any, profileName: any) { +function removeProfile(currentConfig: LoopoverConfig, profileName: string) { const profiles = { ...(currentConfig.profiles ?? {}) }; delete profiles[profileName]; const remaining = Object.keys(profiles); @@ -5170,23 +5237,23 @@ function removeProfile(currentConfig: any, profileName: any) { return normalizeConfig({ ...currentConfig, activeProfile, profiles, session }); } -function setTelemetryEnabled(currentConfig: any, enabled: any) { +function setTelemetryEnabled(currentConfig: LoopoverConfig, enabled: any) { // normalizeConfig coerces this to a strict boolean and strips it when not exactly `true`, so disabling // removes the key entirely (default = absent) rather than persisting `telemetryEnabled: false`. return normalizeConfig({ ...currentConfig, telemetryEnabled: enabled === true ? true : undefined }); } -function hasPersistedConfigState(currentConfig: any) { +function hasPersistedConfigState(currentConfig: LoopoverConfig) { return Boolean(currentConfig.apiUrl || currentConfig.telemetryEnabled === true || Object.keys(currentConfig.profiles ?? {}).length > 0); } -function validationFromOptions(options: any) { +function validationFromOptions(options: CliOptions) { const direct = (options.validation ?? []).map(parseValidationEntry); const commands = options.validationCommand ?? []; const statuses = options.validationStatus ?? []; const summaries = options.validationSummary ?? []; const durations = options.validationDuration ?? []; - const expanded = commands.map((command: any, index: any) => + const expanded = commands.map((command: string, index: any) => validationEntry({ command, statusText: statuses[index], @@ -5277,7 +5344,7 @@ function isValidationStatusLike(value: any) { ); } -function inferValidationExitCode(value: any, options: any = {}) { +function inferValidationExitCode(value: any, options: CliOptions = {}) { const text = String(value ?? "").trim().toLowerCase(); const allowBareCode = options.allowBareCode === true; const allowGenericStatus = options.allowGenericStatus === true; @@ -5329,7 +5396,7 @@ function redactPrivateValidationMetrics(text: any) { ); } -async function getDecisionPackWithCache(login: any) { +async function getDecisionPackWithCache(login: string) { try { const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/decision-pack`); if (isCacheableDecisionPack(payload, login)) writeDecisionPackCache(login, payload); @@ -5342,7 +5409,7 @@ async function getDecisionPackWithCache(login: any) { } } -async function getRepoDecisionWithCache(login: any, owner: any, repo: any) { +async function getRepoDecisionWithCache(login: string, owner: string, repo: string) { const repoFullName = `${owner}/${repo}`; try { return await apiGet(`/v1/contributors/${encodeURIComponent(login)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/decision`); @@ -5368,17 +5435,17 @@ function repoDecisionToolSummary(login: string, repoFullName: string, payload: D return `LoopOver repo decision for ${login} in ${repoFullName}.`; } -function getOpenPrMonitor(login: any) { +function getOpenPrMonitor(login: string) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`); } // #7760: single source of truth for GET /v1/contributors/:login/profile, shared by the contributor-profile CLI // and the loopover_get_contributor_profile stdio tool so neither duplicates the endpoint path. -function getContributorProfile(login: any) { +function getContributorProfile(login: string) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/profile`); } -function getPrOutcomes(login: any, limit: any) { +function getPrOutcomes(login: string, limit: any) { const query = new URLSearchParams(); if (limit != null) query.set("limit", String(limit)); const suffix = query.size > 0 ? `?${query}` : ""; @@ -5387,10 +5454,10 @@ function getPrOutcomes(login: any, limit: any) { // #6745: contributor notification feed + mark-read. `postMarkNotificationsRead` sends no ids to mark all // delivered notifications read, mirroring markNotificationsReadShape's optional ids. -function getNotifications(login: any) { +function getNotifications(login: string) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/notifications`); } -function postMarkNotificationsRead(login: any, ids: any) { +function postMarkNotificationsRead(login: string, ids: any) { return apiPost(`/v1/contributors/${encodeURIComponent(login)}/notifications/read`, ids ? { ids } : {}); } @@ -5418,20 +5485,28 @@ function decisionPackAuthCacheKey() { return createHash("sha256").update(token).digest("base64url"); } -function decisionPackCachePath(login: any, authCacheKey = decisionPackAuthCacheKey()) { +function decisionPackCachePath(login: string, authCacheKey = decisionPackAuthCacheKey()) { if (!authCacheKey) return null; const key = Buffer.from(`${apiUrl}\0${currentApiVersion}\0${login.toLowerCase()}\0${authCacheKey}`).toString("base64url"); return join(decisionPackCacheDir, `${key}.json`); } -function writeDecisionPackCache(login: string, payload: { apiVersion?: unknown }) { +/** + * #9773: `payload.apiVersion` used to be read here, and the entry's version was + * `typeof payload.apiVersion === "string" ? payload.apiVersion : currentApiVersion`. The decision-pack + * response has never carried an `apiVersion`, so that ternary always took its right arm -- which made the + * `entry.apiVersion !== currentApiVersion` guard immediately below it a comparison of a value with itself, + * and its `api_version_mismatch` result unreachable. Both are gone; the stamp is unchanged, so a cache + * written by an older PACKAGE version is still invalidated on read, which is what that check really does. + */ +function writeDecisionPackCache(login: string, payload: unknown) { const authCacheKey = decisionPackAuthCacheKey(); if (!authCacheKey) return { status: "skipped", reason: "missing_auth" }; const cachedAt = new Date().toISOString(); const sanitizedPayload = sanitizeDecisionPackForCache(payload); const entry = { schemaVersion: decisionPackCacheSchemaVersion, - apiVersion: typeof payload.apiVersion === "string" ? payload.apiVersion : currentApiVersion, + apiVersion: currentApiVersion, packageVersion, apiUrl, authCacheKey, @@ -5439,7 +5514,6 @@ function writeDecisionPackCache(login: string, payload: { apiVersion?: unknown } cachedAt, payload: sanitizedPayload, }; - if (entry.apiVersion !== currentApiVersion) return { status: "skipped", reason: "api_version_mismatch" }; const serialized = `${JSON.stringify(entry, null, 2)}\n`; if (Buffer.byteLength(serialized, "utf8") > decisionPackCacheMaxBytes) return { status: "skipped", reason: "too_large" }; mkdirSync(decisionPackCacheDir, { recursive: true, mode: 0o700 }); @@ -5450,7 +5524,7 @@ function writeDecisionPackCache(login: string, payload: { apiVersion?: unknown } return { status: "stored", cachedAt }; } -function readDecisionPackCache(login: any) { +function readDecisionPackCache(login: string) { const authCacheKey = decisionPackAuthCacheKey(); const path = decisionPackCachePath(login, authCacheKey); if (!path || !existsSync(path)) return null; @@ -5463,7 +5537,7 @@ function readDecisionPackCache(login: any) { } } -function isCompatibleDecisionPackCacheEntry(entry: any, login: any, authCacheKey = decisionPackAuthCacheKey()) { +function isCompatibleDecisionPackCacheEntry(entry: any, login: string, authCacheKey = decisionPackAuthCacheKey()) { return ( entry && typeof entry === "object" && @@ -5492,7 +5566,7 @@ function staleDecisionPackFromCache(entry: any, error: any) { }); } -function repoDecisionFromCachedPack(entry: any, repoFullName: any, error: any) { +function repoDecisionFromCachedPack(entry: any, repoFullName: string, error: any) { const pack = staleDecisionPackFromCache(entry, error); const decision = cachedRepoDecision(pack, repoFullName); return stripUndefined({ @@ -5510,7 +5584,7 @@ function repoDecisionFromCachedPack(entry: any, repoFullName: any, error: any) { }); } -function cachedRepoDecision(pack: any, repoFullName: any) { +function cachedRepoDecision(pack: any, repoFullName: string) { const key = repoFullName.toLowerCase(); return pack.repoDecisions?.find((decision: any) => String(decision?.repoFullName ?? "").toLowerCase() === key) ?? null; } @@ -5535,7 +5609,9 @@ function isDecisionPackCacheFallbackEligible(error: any) { return status === 429 || status >= 500; } -function sanitizeDecisionPackForCache(value: any): any { +/** Recursively redact a cached payload. Genuinely `unknown` in, `unknown` out: it walks whatever the + * endpoint returned, narrowing as it goes -- which is what `any` was standing in for. */ +function sanitizeDecisionPackForCache(value: unknown): unknown { if (Array.isArray(value)) return value.map((entry) => sanitizeDecisionPackForCache(entry)); if (typeof value === "string") return sanitizeCacheString(value); if (!value || typeof value !== "object") return value; @@ -5634,7 +5710,7 @@ function listDecisionPackCache() { }; } -function findExecutable(name: any) { +function findExecutable(name: string) { for (const directory of String(process.env.PATH ?? "").split(delimiter).filter(Boolean)) { const candidate = join(directory, name); if (existsSync(candidate)) return candidate; @@ -5760,12 +5836,18 @@ function validatedPathOf(path: string): ValidatedApiPath | null { * would be a false claim about how much is actually validated. */ async function apiGet(path: Path): Promise>; +// #9773: the parameterised overload. The CLI builds these with interpolation, so the pattern is matched at +// the TYPE level rather than by key -- `/v1/contributors/${login}/profile` resolves to the +// `/v1/contributors/{login}/profile` schema without the call site naming it. A path matching no known +// pattern has `MatchApiPath` = never and falls through to the untyped overload below, exactly as before. +async function apiGet(path: MatchApiPath extends never ? never : Path): Promise>; async function apiGet(path: string): Promise; async function apiGet(path: string) { return apiFetch(path, { method: "GET" }); } async function apiPost(path: Path, body: unknown): Promise>; +async function apiPost(path: MatchApiPath extends never ? never : Path, body: unknown): Promise>; async function apiPost(path: string, body: unknown): Promise; async function apiPost(path: string, body: unknown) { return apiFetch(path, { method: "POST", body: JSON.stringify(body) }); @@ -6219,10 +6301,10 @@ function localDiffTargetKey(branchPayload: any, baseRef: any) { .join(":"); } -function branchEligibilityFromOptions(options: any) { - const status = options.branchEligibility ?? options.branchEligibilityStatus; - if (!["eligible", "ineligible", "unknown"].includes(status)) return undefined; - const source = ["github_metadata", "local_metadata", "registry", "user_supplied"].includes(options.branchEligibilitySource) ? options.branchEligibilitySource : "user_supplied"; +function branchEligibilityFromOptions(options: CliOptions) { + const status = optionText(options.branchEligibility) ?? optionText(options.branchEligibilityStatus); + if (!status || !["eligible", "ineligible", "unknown"].includes(status)) return undefined; + const source = ["github_metadata", "local_metadata", "registry", "user_supplied"].includes(optionText(options.branchEligibilitySource) ?? "") ? optionText(options.branchEligibilitySource)! : "user_supplied"; return stripUndefined({ status, source, diff --git a/scripts/gen-contract-api-schemas.ts b/scripts/gen-contract-api-schemas.ts index 866a9c1bf8..5b2d2ab9ee 100644 --- a/scripts/gen-contract-api-schemas.ts +++ b/scripts/gen-contract-api-schemas.ts @@ -43,6 +43,52 @@ export function cliApiPaths(binSource: string): string[] { return [...paths].sort(); } +/** + * Every PARAMETERISED `/v1/...` path the CLI calls, normalised to the document's own `{param}` form (#9773). + * + * `cliApiPaths` above deliberately rejects anything containing a `$`, so until now the 53 template call + * sites -- every per-contributor and per-repo endpoint -- fell through to the untyped overload. That, not + * an oversight in the call sites, is why the stdio bin still reads those payloads as `any`. + * + * Each `${...}` becomes `{}` first (its own contents can be an arbitrary expression, including nested + * braces and a `?:` with slashes in both arms), then the segments are re-keyed positionally against the + * document's parameter names, so the emitted key is exactly the string `openapi.json` uses. + */ +export function cliParameterisedApiPaths(binSource: string, document: OpenApiDocument): string[] { + const documented = Object.keys(document.paths).filter((path) => path.includes("{")); + const shapes = new Map(); + for (const documentPath of documented) shapes.set(documentPath.replace(/\{[^}]+\}/g, "{}"), documentPath); + + const found = new Set(); + for (const match of binSource.matchAll(/api(?:Get|Post|Delete|Fetch)\(\s*`(\/v1\/[^`]*)`/g)) { + const raw = match[1]!; + if (!raw.includes("${")) continue; + // Collapse each interpolation, honouring nested braces, then drop any trailing query the template adds. + let collapsed = ""; + for (let index = 0; index < raw.length; index += 1) { + if (raw[index] === "$" && raw[index + 1] === "{") { + let depth = 1; + index += 2; + while (index < raw.length && depth > 0) { + if (raw[index] === "{") depth += 1; + else if (raw[index] === "}") depth -= 1; + index += 1; + } + index -= 1; + collapsed += "{}"; + } else { + collapsed += raw[index]; + } + } + const withoutQuery = collapsed.split("?")[0]!.replace(/\/+$/, ""); + // A template whose interpolation spans a slash (a conditional query suffix, say) cannot be a path + // shape; it simply will not match a documented one, and is left unvalidated exactly as before. + const documentPath = shapes.get(withoutQuery); + if (documentPath) found.add(documentPath); + } + return [...found].sort(); +} + type SchemaBlock = { name: string; source: string; exported: boolean }; /** Every top-level `const XSchema = ...` in the source, in declaration order, with its full body. */ @@ -108,8 +154,10 @@ import { z } from "zod"; `; export function renderApiSchemas(sourceText: string, documentText: string, binSource: string): string { - const byPath = responseSchemaByPath(JSON.parse(documentText) as OpenApiDocument, cliApiPaths(binSource)); - const blocks = closure(parseSchemaBlocks(sourceText), [...new Set(byPath.values())]); + const document = JSON.parse(documentText) as OpenApiDocument; + const byPath = responseSchemaByPath(document, cliApiPaths(binSource)); + const byPattern = responseSchemaByPath(document, cliParameterisedApiPaths(binSource, document)); + const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values()])]); const body = blocks .map((block) => block.source @@ -122,7 +170,11 @@ export function renderApiSchemas(sourceText: string, documentText: string, binSo .sort(([left], [right]) => left.localeCompare(right)) .map(([path, schema]) => ` "${path}": ${schema},`) .join("\n"); - return `${HEADER}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${TABLE_TYPES}`; + const patternTable = [...byPattern.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, schema]) => ` "${path}": ${schema},`) + .join("\n"); + return `${HEADER}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${PATTERN_TABLE_HEADER}${patternTable}\n} as const;\n\n${TABLE_TYPES}`; } const TABLE_HEADER = `/** @@ -135,11 +187,46 @@ const TABLE_HEADER = `/** export const CLI_RESPONSE_SCHEMAS = { `; +const PATTERN_TABLE_HEADER = `/** + * The same, for the PARAMETERISED paths (#9773) -- keyed by the document's own \`{param}\` template. + * + * Separate from the table above because these cannot be looked up by an exact string: the CLI builds them + * with interpolation, so the match happens at the type level (see MatchApiPath) rather than by key. + */ +export const CLI_PARAMETERISED_RESPONSE_SCHEMAS = { +`; + const TABLE_TYPES = `/** A path the client validates. */ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; /** The parsed response type for a validated path -- what the CLI call sites get instead of \`any\`. */ export type ApiResponse = z.infer<(typeof CLI_RESPONSE_SCHEMAS)[Path]>; + +/** A parameterised path pattern the client validates. */ +export type ParameterisedApiPath = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; + +/** + * A pattern with every \`{param}\` widened to \`\${string}\`, so a concrete path can be matched against it. + * + * Recursive because a pattern can carry several parameters + * (\`/v1/contributors/{login}/repos/{owner}/{repo}/decision\`). + */ +export type TemplatedApiPath = Pattern extends \`\${infer Head}{\${string}}\${infer Tail}\` + ? \`\${Head}\${string}\${TemplatedApiPath}\` + : Pattern; + +/** + * The pattern a CONCRETE path matches, or \`never\` when it matches none. + * + * This is what lets the CLI keep writing its natural interpolated template and still get the exact response + * type: the mapped type distributes over every known pattern and keeps only the arms the string satisfies. + */ +export type MatchApiPath = { + [Pattern in ParameterisedApiPath]: Path extends TemplatedApiPath ? Pattern : never; +}[ParameterisedApiPath]; + +/** The parsed response for a concrete parameterised path. */ +export type ParameterisedApiResponse = z.infer<(typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiPath]>; `; export function generate(deps: { readFile?: (path: string) => string } = {}): string { diff --git a/test/unit/mcp-cli-bool-flag-parsing.test.ts b/test/unit/mcp-cli-bool-flag-parsing.test.ts index 087b73f8c8..73d3043041 100644 --- a/test/unit/mcp-cli-bool-flag-parsing.test.ts +++ b/test/unit/mcp-cli-bool-flag-parsing.test.ts @@ -133,3 +133,27 @@ describe("loopover-mcp CLI — boolean `--flag=value` parsing (#8689)", () => { expect(out).toContain("[mcp_servers.loopover]"); }); }); + +// #9773: three crashes the `: any` on the option plumbing was hiding. Each is a real invocation a user can +// type; each threw or lied before this. Typing parseOptions from CLI_FLAG_SPEC is what surfaced all three. +describe("a flag given with no value (#9773)", () => { + it("does not let a bare repeatable flag poison the next one", async () => { + // `--issue --issue 5` stored `true` for the first, then spread it: "true is not iterable". + const out = await withEnv(AUTHED, () => + captureStdout(() => mod.runCli(["preflight", "--login", "acme", "--repo", "acme/widgets", "--title", "t", "--body", "b", "--issue", "--issue", "5", "--json"])), + ); + expect(out.length).toBeGreaterThan(0); + }); + + it("reports the usage error for `maintain --repo` instead of throwing a TypeError", async () => { + // Was: "repoFullName.includes is not a function" -- a bare flag parses to `true`, which passed the + // truthiness guard and then died on a string method. + await expect(withEnv(AUTHED, () => captureStdout(() => mod.runCli(["maintain", "list", "--repo"])))).rejects.toThrow("Pass --repo owner/repo."); + }); + + it("treats a bare --login as absent rather than as a contributor NAMED \"true\"", async () => { + // Was: `/v1/contributors/true/decision-pack` -- a real request for a real-looking login, answered with + // "not found", when the user had simply forgotten the value. + await expect(withEnv({ ...AUTHED, LOOPOVER_LOGIN: undefined, GITHUB_LOGIN: undefined }, () => captureStdout(() => mod.runCli(["decision-pack", "--login"])))).rejects.toThrow(/--login/); + }); +}); diff --git a/test/unit/mcp-cli-contributor-profile-inprocess.test.ts b/test/unit/mcp-cli-contributor-profile-inprocess.test.ts index 6f4316d1c9..8efb3addb8 100644 --- a/test/unit/mcp-cli-contributor-profile-inprocess.test.ts +++ b/test/unit/mcp-cli-contributor-profile-inprocess.test.ts @@ -90,11 +90,10 @@ describe("bin loopover_get_contributor_profile stdio tool (in-process, #7760)", expect(captured.url).toContain("/v1/contributors/octocat/profile"); expect(captured.method).toBe("GET"); expect(result.isError).toBeFalsy(); - // structuredContent is the raw API payload; the summary line is the remote tool's fixed sentence. - expect(result.structuredContent).toMatchObject({ login: "octocat" }); + // structuredContent is the raw API payload; the summary line is the tool's own fixed sentence. + expect(result.structuredContent).toMatchObject({ login: "octocat", source: "github_cache" }); const text = JSON.stringify(result); expect(text).toContain("LoopOver contributor profile for octocat."); - expect(text).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling."); } finally { await client.close().catch(() => undefined); } @@ -117,19 +116,20 @@ describe("bin loopover_get_contributor_profile stdio tool (in-process, #7760)", }); describe("bin contributor-profile CLI (in-process, #7760)", () => { - it.each(MODULES)("shares getContributorProfile with the stdio tool: prints the header + API summary — %s", async (specifier) => { + it.each(MODULES)("shares getContributorProfile with the stdio tool: prints the header — %s", async (specifier) => { capturedRequests.length = 0; const mod = loaded.get(specifier)!; const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat" })); expect(capturedRequests.at(-1)!.url).toBe("/v1/contributors/octocat/profile"); + // #9773: the "API summary" line this used to assert came from a `summary` field the endpoint has never + // returned -- invented by the fixture, read by the CLI, asserted here. The header is what really prints. expect(out).toMatch(/LoopOver contributor profile for octocat\./); - expect(out).toContain("3 registered repos; 12 merged PRs; strongest in review-tooling."); }); it.each(MODULES)("--json re-serializes the same payload the shared call returned — %s", async (specifier) => { const mod = loaded.get(specifier)!; const out = await captureStdout(() => mod.contributorProfileCli({ login: "octocat", json: true })); - const payload = JSON.parse(out) as { login: string; summary: string }; - expect(payload).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." }); + const payload = JSON.parse(out) as { login: string; source: string }; + expect(payload).toMatchObject({ login: "octocat", source: "github_cache" }); }); }); diff --git a/test/unit/mcp-cli-contributor-profile.test.ts b/test/unit/mcp-cli-contributor-profile.test.ts index cb5d5ad6ac..ee84d2200a 100644 --- a/test/unit/mcp-cli-contributor-profile.test.ts +++ b/test/unit/mcp-cli-contributor-profile.test.ts @@ -25,12 +25,12 @@ describe("loopover-mcp CLI — contributor-profile (#6737)", () => { const plain = await runAsync(["contributor-profile", "--login", "octocat"], e); expect(plain).toMatch(/LoopOver contributor profile for octocat\./); - expect(plain).toMatch(/3 registered repos; 12 merged PRs; strongest in review-tooling\./); expect(requests.at(-1)).toBe("/v1/contributors/octocat/profile"); - const json = JSON.parse(await runAsync(["contributor-profile", "--login", "octocat", "--json"], e)) as { login: string; summary: string }; - // Parity: the --json surface re-serializes the same payload the plain summary was built from. - expect(json).toMatchObject({ login: "octocat", summary: "3 registered repos; 12 merged PRs; strongest in review-tooling." }); + const json = JSON.parse(await runAsync(["contributor-profile", "--login", "octocat", "--json"], e)) as { login: string; source: string }; + // Parity: the --json surface re-serializes the payload verbatim. Asserted on fields the endpoint really + // returns -- the previous assertion named a `summary` that only ever existed in the fixture (#9773). + expect(json).toMatchObject({ login: "octocat", source: "github_cache" }); }); it("resolves the login from LOOPOVER_LOGIN when --login is omitted, and url-encodes it", async () => { diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index d91f6cd0dc..2e72b803b5 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -387,7 +387,11 @@ export async function startFixtureServer( JSON.stringify({ login: decodeURIComponent(contributorProfileMatch[1]!), generatedAt: "2026-05-30T00:00:00.000Z", - summary: "3 registered repos; 12 merged PRs; strongest in review-tooling.", + // #9773: a `summary` used to be here. The real endpoint has never returned one -- neither + // ContributorProfile shape carries the field, and the string existed nowhere but this fixture -- + // so it was an invented field the CLI then read and the test then asserted on. All three agreed + // with each other and none agreed with the server. + source: "github_cache", }), ); return; From 6a83431cb79b1ac6641fec5f7a72eb6fb682ab39 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:22:02 -0700 Subject: [PATCH 2/4] types(mcp): type the CLI's parameterised API calls from the published document (#9773) The second tranche. #9521 built the typed accessors; its scanner rejected any template containing an interpolation, so every per-repo and per-contributor call -- the majority of the CLI -- missed the typed overload and read its payload as `any`. The document already described most of them. Three things had to change for a composed call to resolve. The path builders now DECLARE their shape. `toolRepoBase` returned `string`, which erases the path at the type level, so `apiGet(`${toolRepoBase(o, r)}/settings`)` could never match anything; it and the 24 locally-built bases now carry template-literal types. That is also what lets the generator's scanner resolve them: it reads the same declarations the type checker does, so the two cannot disagree about what a base is. The tables are keyed by METHOD, not by path. `/v1/repos/{owner}/{repo}/agent/pending-actions` lists on GET and proposes on POST, and those return different shapes -- a path-keyed table had to guess, and the first version of this guessed `post`, handing the GET call site the POST response type. The CLI's own `payload.pendingActions` read is what contradicted it, the moment a schema was attached at all. Caught by the type checker before it shipped. And the generated copy now carries what a copied schema REFERENCES. `closure` followed only `*Schema` names, so a schema depending on a plain value beside it (`AGENT_ACTION_CLASS_VALUES`) emitted a file that would not compile. Values declared in the source are copied; anything else is imported from the contract's limits.ts, where it is restated and pinned -- and a bound missing there fails the contract build rather than emitting something broken. 30 parameterised calls are typed now, up from 8, and the guards are in mcp-api-client.test.ts: method disambiguation, base-path resolution, the copied-value closure, and the prose false-positive the first cut of the constant scanner hit (it emitted imports for DELETE, REQUIRED and REST, read out of doc comments). Still `any` at the fallback overload, for the endpoints whose 200 the document does not describe with a named schema. Flipping that to `unknown` leaves 72 narrowing sites, and the honest fix for them is to describe those endpoints -- #9773 stays open for it. --- packages/loopover-contract/src/api-schemas.ts | 884 +++++++++++++++++- packages/loopover-contract/src/limits.ts | 12 + packages/loopover-mcp/bin/loopover-mcp.ts | 64 +- scripts/gen-contract-api-schemas.ts | 125 ++- test/unit/mcp-api-client.test.ts | 57 +- 5 files changed, 1076 insertions(+), 66 deletions(-) diff --git a/packages/loopover-contract/src/api-schemas.ts b/packages/loopover-contract/src/api-schemas.ts index 39ffdec117..1b87e8c694 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -8,6 +8,8 @@ // boundary has no other way to share a composed schema's parts. import { z } from "zod"; +import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "./limits.js"; + export const FindingSchema = z .object({ code: z.string(), @@ -47,6 +49,20 @@ export const RegistrySnapshotSchema = z repositories: z.array(RegistryRepoSchema), }); +export const RepositorySchema = z + .object({ + fullName: z.string(), + owner: z.string(), + name: z.string(), + installationId: z.number().nullable().optional(), + isInstalled: z.boolean(), + isRegistered: z.boolean(), + isPrivate: z.boolean(), + htmlUrl: z.string().nullable().optional(), + defaultBranch: z.string().nullable().optional(), + registryConfig: RegistryRepoSchema.nullable().optional(), + }); + export const LaneAdviceSchema = z .object({ lane: z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"]), @@ -84,6 +100,49 @@ export const CollisionClusterSchema = z items: z.array(CollisionItemSchema), }); +export const QueueHealthSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + burdenScore: z.number(), + level: z.enum(["low", "medium", "high", "critical"]), + summary: z.string(), + // #9517: draftPullRequests, slopFlaggedPullRequests and duplicateFlaggedPullRequests are all + // REQUIRED on the QueueHealth type and always emitted by buildQueueHealth, but were missing + // here -- the published spec understated the response. The two flagged counts are deliberately + // public-safe counts, carrying no score or ranking detail. + signals: z.object({ + openIssues: z.number(), + openPullRequests: z.number(), + unlinkedPullRequests: z.number(), + stalePullRequests: z.number(), + draftPullRequests: z.number(), + maintainerAuthoredPullRequests: z.number(), + collisionClusters: z.number(), + slopFlaggedPullRequests: z.number(), + duplicateFlaggedPullRequests: z.number(), + ageBuckets: z.object({ + under7Days: z.number(), + days7To30: z.number(), + over30Days: z.number(), + }), + likelyReviewablePullRequests: z.number(), + cachedOpenPullRequests: z.number().optional(), + likelyReviewablePullRequestsSource: z.enum(["cache", "sampled_cache", "authoritative"]).optional(), + }), + findings: z.array(FindingSchema), + rankedPullRequests: z + .array( + z.object({ + number: z.number(), + title: z.string(), + authorLogin: z.string(), + recommendation: z.string(), + }), + ) + .optional(), + }); + export const ContributorProfileSchema = z .object({ login: z.string(), @@ -290,6 +349,34 @@ export const LocalDiffPreflightResultSchema = PreflightResultSchema.extend({ }), }); +export const PullRequestMaintainerPacketSchema = z + .object({ + repoFullName: z.string(), + pullNumber: z.number(), + generatedAt: z.string(), + reviewPriority: z.enum(["review", "needs_author", "watch"]), + summary: z.string(), + changeSummary: z.object({ + fileCount: z.number(), + codeFileCount: z.number(), + testFileCount: z.number(), + additions: z.number(), + deletions: z.number(), + topPaths: z.array(z.string()), + }), + reviewSignals: z.object({ + reviewCount: z.number(), + approvalCount: z.number(), + changeRequestCount: z.number(), + checkFailureCount: z.number(), + linkedIssues: z.array(z.number()), + collisionClusters: z.number(), + }), + findings: z.array(FindingSchema), + contributorNextSteps: z.array(z.string()), + maintainerNotes: z.array(z.string()), + }); + export const BountySourceContextSchema = z.object({ sourceUrl: z.string().nullable().optional(), discoveredAt: z.string().nullable().optional(), @@ -305,6 +392,16 @@ export const BountyLinkedPrSchema = z.object({ isActive: z.boolean(), }); +export const BountyOpportunityContextSchema = z.object({ + id: z.string(), + lifecycle: z.enum(["active", "historical", "completed", "cancelled", "stale", "ambiguous", "unknown"]), + isActiveOpportunity: z.boolean(), + fundingStatus: z.enum(["funded", "target_only", "unknown"]), + consensusRisk: z.enum(["low", "medium", "high"]), + source: BountySourceContextSchema, + linkedPrs: z.array(BountyLinkedPrSchema), +}); + export const BountyAdvisorySchema = z .object({ id: z.string(), @@ -320,6 +417,332 @@ export const BountyAdvisorySchema = z findings: z.array(FindingSchema), }); +export const RepositorySettingsSchema = z + .object({ + repoFullName: z.string(), + commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), + publicAudienceMode: z.enum(["oss_maintainer", "gittensor_only"]), + publicSignalLevel: z.enum(["minimal", "standard"]), + checkRunMode: z.enum(["off", "enabled"]), + checkRunDetailLevel: z.enum(["minimal", "standard"]), + regateSweepOrderMode: z.enum(["staleness", "oldest-first"]), + reviewCheckMode: z.enum(["required", "visible", "disabled"]), + autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), + autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + gatePack: z.enum(["gittensor", "oss-anti-slop"]), + linkedIssueGateMode: z.enum(["off", "advisory", "block"]), + duplicatePrGateMode: z.enum(["off", "advisory", "block"]), + qualityGateMode: z.enum(["off", "advisory", "block"]), + qualityGateMinScore: z.number().nullable().optional(), + closeAuditHoldoutPct: z.number().nullable().optional(), + slopGateMode: z.enum(["off", "advisory", "block"]), + sizeGateMode: z.enum(["off", "advisory", "block"]).optional(), + sizeGateMaxFiles: z.number().optional(), + sizeGateMaxLines: z.number().optional(), + lockfileIntegrityGateMode: z.enum(["off", "advisory", "block"]).optional(), + claGateMode: z.enum(["off", "advisory", "block"]).optional(), + claConsentPhrase: z.string().nullable().optional(), + claCheckRunName: z.string().nullable().optional(), + claCheckRunAppSlug: z.string().nullable().optional(), + // #9531: `expectedCiContexts` is declared `| null` on the type and read as a distinct value from absent + // (focus-manifest.ts's `!== null` gate), but the published schema omitted `.nullable()` -- so a client + // generated from the spec typed it as never-null, unlike its identically-shaped sibling below. Found by + // the compile-time parity assertion in test/unit/openapi-settings-schema-parity.test.ts. + expectedCiContexts: z.array(z.string()).readonly().nullable().optional(), + advisoryCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(), + copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(), + copycatGateMinScore: z.number().nullable().optional(), + gateDryRun: z.boolean().optional(), + premergeContentRecheck: z.boolean().optional(), + requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(), + staleBaseAheadByThreshold: z.number().int().positive().nullable().optional(), + mergeReadinessGateMode: z.enum(["off", "advisory", "block"]), + manifestPolicyGateMode: z.enum(["off", "advisory", "block"]), + selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]), + linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]), + contentLaneDeliverableGateMode: z.enum(["off", "advisory", "block"]), + backtestRegressionGateMode: z.enum(["off", "advisory", "block"]), + slopGateMinScore: z.number().nullable().optional(), + slopAiAdvisory: z.boolean(), + aiReviewMode: z.enum(["off", "advisory", "block"]), + aiReviewByok: z.boolean(), + aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(), + aiReviewModel: z.string().nullable().optional(), + aiReviewAllAuthors: z.boolean(), + aiReviewConfirmedContributorsOnly: z.boolean().nullable().optional(), + aiReviewCloseConfidence: z.number().nullable().optional(), + aiReviewSalvageabilityMinScore: z.number().nullable().optional(), + aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).nullable().optional(), + aiReviewCombine: z.enum(["single", "consensus", "synthesis"]).nullable().optional(), + aiReviewOnMerge: z.enum(["either", "both"]).nullable().optional(), + aiReviewReviewers: z + .array(z.object({ model: z.string(), fallback: z.string().nullable().optional() })) + .readonly() + .nullable() + .optional(), + closeOwnerAuthors: z.boolean(), + autoLabelEnabled: z.boolean(), + typeLabelsEnabled: z.boolean(), + // Open `category -> label name` record (#label-modularity): bug/feature/priority are the built-in + // categories, but a self-hoster may register any number of additional ones (e.g. `security`). + typeLabels: z.record(z.string(), z.string()).optional(), + // loopover_plan_repo_issues config-as-code surface (#7429) -- see the field's own doc comment on + // RepositorySettings (src/types.ts) for the full contract. + issuePlanEnabled: z.boolean().optional(), + issuePlanExtraLabels: z.array(z.string()).optional(), + issuePlanMilestoneReuse: z.boolean().optional(), + linkedIssueLabelPropagation: z + .object({ + enabled: z.boolean(), + mode: z.enum(["exclusive_type_label"]), + mappings: z.array( + z.object({ + issueLabel: z.string(), + prLabel: z.string(), + removeOtherTypeLabels: z.boolean(), + trustMaintainerAuthoredIssue: z.boolean().optional(), + trustMaintainerAuthoredIssueForReward: z.boolean().optional(), + }), + ), + }), + linkedIssueHardRules: z + .object({ + ownerAssignedClose: z.enum(["block", "off"]), + assignedIssueClose: z.enum(["block", "off"]), + missingPointLabelClose: z.enum(["block", "off"]), + maintainerOnlyLabelClose: z.enum(["block", "off"]), + pointBearingLabels: z.array(z.string()), + maintainerOnlyLabels: z.array(z.string()), + defaultLabelRepo: z.boolean(), + verifyBeforeClose: z.boolean(), + closeDelaySeconds: z.number().int().min(0).max(300), + }), + unlinkedIssueGuardrail: z + .object({ + mode: z.enum(["hold", "off"]), + minConfidence: z.number().min(0).max(1), + }) + .optional(), + advisoryAiRouting: z + .object({ + slop: z.boolean(), + e2eTestGen: z.boolean(), + planner: z.boolean(), + summaries: z.boolean(), + chatQa: z + .boolean() + .describe( + "Opt the `@loopover chat ` grounded Q&A surface (#4595) into local Ollama inference. Ollama-first: unlike the four capabilities above, it declines instead of falling back to the frontier env.AI when env.AI_ADVISORY is unconfigured, unless `chatQaFrontierFallback` is also enabled. Co-requisite: set `commandRateLimitPolicy` to `hold` (it defaults to `off` fleet-wide) so the tighter `commandRateLimitAiMaxPerWindow` ceiling actually throttles this cost-bearing command.", + ), + chatQaFrontierFallback: z + .boolean() + .describe( + "Opt-in only (#4595 follow-up): when true, `@loopover chat` falls back to the shared frontier env.AI chain if env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless `chatQa` is also true. Default false -- a self-hoster without a local GPU may enable this to use their own frontier subscription/tokens for chat instead.", + ), + intentRouting: z + .boolean() + .describe( + "Opt a closed-set intent-classification router (#4596) into unrecognized `@loopover` mentions: maps a free-text question to the closest existing Q&A command (never an action command) instead of the plain did-you-mean hint. Ollama-only, same as chatQa. Co-requisite: set `commandRateLimitPolicy` to `hold`.", + ), + }) + .optional(), + gittensorLabel: z.string(), + blacklistLabel: z.string().nullable(), + createMissingLabel: z.boolean(), + publicSurface: z + .enum(["off", "comment_and_label", "comment_only", "label_only"]) + .describe( + "Governs ONLY the PR comment and label -- never the LoopOver Context check (checkRunMode) or the LoopOver Orb Review Agent gate check (reviewCheckMode), which are independent axes by design (#2852). Setting this to \"off\" does NOT silence either check-run.", + ), + includeMaintainerAuthors: z.boolean(), + requireLinkedIssue: z.boolean(), + backfillEnabled: z.boolean(), + badgeEnabled: z.boolean().optional(), + publicQualityMetrics: z.boolean().optional(), + commandAuthorization: z.object({ + default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), + commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))), + }), + contributorBlacklist: z.array( + z.object({ + login: z.string(), + // #9125's immutable-id field, returned by the API since it landed and absent from this + // schema until the parity assertion flagged it (#9531). + githubId: z.number().int().optional(), + reason: z.string().optional(), + evidence: z.array(z.string()).optional(), + addedAt: z.string().optional(), + }), + ), + // #9531: partialRecord, because AutonomyPolicy is Partial> -- a repo configures the + // classes it configures. And the LIVE level set: "suggest"/"propose" were removed server-side by + // #4620 and silently dropped on persist ever since, yet this schema kept advertising them -- the + // exact drift class #9517's enum notes warned about, republished here. The compile-time parity + // assertion in src/openapi/schema-type-parity.ts is what finally caught it. + autonomy: z.partialRecord( + z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"]), + z.enum(["observe", "auto_with_approval", "auto"]), + ), + autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), + agentPaused: z.boolean().optional(), + agentDryRun: z.boolean().optional(), + contributorOpenPrCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(), + contributorOpenIssueCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(), + contributorCapLabel: z.string().nullable().optional(), + contributorCapCancelCi: z.boolean().nullable().optional(), + reviewNagPolicy: z.enum(["off", "hold", "close"]).optional(), + reviewNagMaxPings: z.number().int().positive().optional(), + reviewNagCooldownDays: z.number().int().positive().max(MAX_REVIEW_NAG_COOLDOWN_DAYS).optional(), + reviewNagLabel: z.string().nullable().optional(), + reviewNagMonitoredMentions: z.array(z.string()).optional(), + autoCloseExemptLogins: z.array(z.string()).optional(), + hardGuardrailGlobs: z.array(z.string()).nullable().optional(), + hardGuardrailGlobsOverridesInvariants: z.boolean().nullable().optional(), + manualReviewLabel: z.string().nullable().optional(), + readyToMergeLabel: z.string().nullable().optional(), + changesRequestedLabel: z.string().nullable().optional(), + migrationCollisionLabel: z.string().nullable().optional(), + pendingClosureLabel: z.string().nullable().optional(), + accountAgeThresholdDays: z.number().int().positive().nullable().optional(), + newAccountLabel: z.string().optional(), + commandRateLimitPolicy: z.enum(["off", "hold"]).optional(), + commandRateLimitMaxPerWindow: z.number().int().positive().optional(), + commandRateLimitAiMaxPerWindow: z.number().int().positive().optional(), + commandRateLimitWindowHours: z.number().int().positive().optional(), + moderationGateMode: z + .enum(["inherit", "off", "enabled"]) + .describe( + "Gates ONLY the shared cross-repo violation tally -- does NOT disable the four underlying anti-abuse mechanisms (contributor cap, blacklist, review-nag, review-evasion), each of which runs on its own independent setting.", + ), + // #9531: "copycat" is a real member of the type's union and a real value this route can return -- the + // published enum simply never gained it when the mechanism landed, so a client generated from the spec + // rejected a payload the API legitimately produces. + moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag", "review_evasion", "copycat"])).optional(), + moderationWarningLabel: z.string().optional(), + moderationBannedLabel: z.string().optional(), + fairnessAnalyticsMode: z + .enum(["inherit", "off", "enabled"]) + .describe( + "Per-repo participation in cross-repo contributor fairness/accuracy analytics -- 'off' excludes this repo's gate decisions and moderation history from every aggregation, independent of whether the internal fairness-analytics routes are enabled fleet-wide.", + ), + skipAutomationBotAuthors: z.enum(["inherit", "off", "enabled"]).optional(), + duplicateWinnerMode: z.enum(["inherit", "off", "enabled"]).optional(), + openPrFileCollisionMode: z.enum(["inherit", "off", "enabled"]).optional(), + plannerMode: z.enum(["inherit", "off", "enabled"]).optional(), + reviewEvasionProtection: z + .enum(["off", "close"]) + .describe( + "Effective default is \"close\" as of #4011 -- \"off\" is an explicit opt-out, not the default. \"off\" only suppresses the enforcement close; the ready<->draft cycling counter keeps incrementing regardless, so re-enabling can immediately treat a historical off-period cycle as \"repeated.\"", + ), + reviewEvasionLabel: z.string().nullable().optional(), + reviewEvasionComment: z.boolean().optional(), + draftPrClosePolicy: z + .enum(["off", "close"]) + .describe( + "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion.", + ), + synchronizeClosePolicy: z + .enum(["off", "close"]) + .describe( + "Off by default (opt-in, config-as-code only -- no dashboard/DB column). \"close\" closes a contributor's own PR immediately when they push an additional commit (synchronize) before it's been merged or closed -- this repo's review is one-shot, so the first push is the only push. Never fires for a push that isn't from the PR's own author (e.g. the engine's own rebase-if-behind), nor for the repo owner/admin, a protected automation author, or anyone with write+ collaborator access.", + ), + mergeTrainMode: z.enum(["off", "audit", "enforce"]).optional(), + screenshotTableGate: z + .object({ + enabled: z.boolean(), + whenLabels: z.array(z.string()), + whenPaths: z.array(z.string()), + action: z.enum(["close", "advisory"]), + requireViewports: z.array(z.string()), + requireThemes: z.array(z.string()), + message: z.string().optional(), + skillFileUrl: z.string().optional(), + }), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + }); + +// #6742: the derived automation view returned by GET /v1/repos/:owner/:repo/automation-state, matching +// buildAutomationState's AutomationState shape. Distinct from RepositorySettings: these are computed fields +// (mode/permissionReadiness/pendingActionCount/acting classes), not the stored settings row. +export const AGENT_ACTION_CLASS_VALUES = [ + "review", + "request_changes", + "approve", + "merge", + "close", + "label", + "review_state_label", + "update_branch", + "assign", +] as const; +export const AUTONOMY_LEVEL_VALUES = ["observe", "auto_with_approval", "auto"] as const; + +export const AutomationStateSchema = z + .object({ + repoFullName: z.string(), + configured: z.boolean(), + autonomy: z.record(z.enum(AGENT_ACTION_CLASS_VALUES), z.enum(AUTONOMY_LEVEL_VALUES)), + autoMaintain: z.boolean().nullable().optional(), + agentPaused: z.boolean(), + agentDryRun: z.boolean(), + mode: z.enum(["paused", "dry_run", "live"]), + permissionReadiness: z.enum(["not_required", "ready", "reconsent_required"]), + actingActionClasses: z.array(z.enum(AGENT_ACTION_CLASS_VALUES)), + pendingActionCount: z.number(), + }); + +// #6743 — the public result shape of the loopover_refresh_repo_docs MCP tool and its REST +// (`POST /v1/repos/:owner/:repo/repo-docs/refresh`) mirror. Both trim RepoDocPullRequestResult's internal +// `claudeMode` field (src/github/repo-doc-pr.ts) the same way, so this schema matches what each surface +// actually returns, not the runner's raw result. +export const RepoDocRefreshResultSchema = z + .discriminatedUnion("opened", [ + z.object({ opened: z.literal(true), reused: z.boolean(), pullNumber: z.number().int(), url: z.string() }), + z.object({ opened: z.literal(false), reason: z.string() }), + ]); + +// #9307 — the agent approval-queue routes under /v1/repos/:owner/:repo/agent/pending-actions, documented to +// match the loopover_propose_action / loopover_list_pending_actions / loopover_decide_pending_action MCP tools +// they mirror (src/mcp/server.ts). The audit-feed neighbor (same agent/* prefix) is already documented; these +// three were the gap. Field-level source of truth is each MCP tool's raw Zod shape, hand-mirrored here so the +// codegen path never pulls the heavy MCP server module into the OpenAPI build. +export const PendingActionEntrySchema = z.object({ + id: z.string(), + actionClass: z.string(), + pullNumber: z.number(), + status: z.string(), + autonomyLevel: z.string(), + reason: z.string().nullable(), + decidedBy: z.string().nullable(), + decidedAt: z.string().nullable(), + createdAt: z.string(), +}); + +export const ListPendingActionsResponseSchema = z + .object({ + repoFullName: z.string().optional(), + status: z.string().optional(), + pendingActions: z.array(PendingActionEntrySchema).optional(), + }); + +// Mirrors `ProposeActionInput` in @loopover/contract minus owner/repo (both are path params on the REST route), matching the request +// body proposePendingActionSchema already validates in src/api/routes.ts. +export const ProposeActionResponseSchema = z + .object({ + created: z.boolean().optional(), + action: z + .object({ id: z.string(), actionClass: z.string(), pullNumber: z.number(), status: z.string(), reason: z.string().nullable() }) + .optional(), + }); + +export const DecidePendingActionResponseSchema = z + .object({ + status: z.string().optional(), + executionOutcome: z.string().optional(), + action: PendingActionEntrySchema.optional(), + }); + export const SkippedPrAuditExportSchema = z .object({ generatedAt: z.string(), @@ -510,6 +933,78 @@ export const ScorePreviewSchema = z generatedAt: z.string(), }); +export const IssueQualityReportSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + lane: LaneAdviceSchema, + issues: z.array( + z.object({ + number: z.number(), + title: z.string(), + lifecycle: z.enum(["open", "closed_not_solved", "solved", "valid_solved", "stale", "duplicate", "invalid"]).optional(), + linkage: z + .object({ + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]), + source: z.enum(["official_mirror", "github_cache", "missing"]), + solvedByPullRequests: z.array(z.number()), + reason: z.string(), + warnings: z.array(z.string()), + }) + .optional(), + bounty: BountyOpportunityContextSchema.optional(), + status: z.enum(["ready", "needs_proof", "hold", "do_not_use"]), + score: z.number(), + reasons: z.array(z.string()), + warnings: z.array(z.string()), + }), + ), + summary: z.string(), + }); + +export const IssueQualityResponseSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["snapshot", "computed"]), + repoFullName: z.string(), + generatedAt: z.string(), + report: IssueQualityReportSchema, + }); + +/** AMS probe payload for ORB live gate thresholds (#6486) — snake_case column names only. */ +export const LiveGateThresholdsResponseSchema = z + .object({ + repoFullName: z.string(), + confidence_floor: z.number().nullable(), + scope_cap_files: z.number().int().nullable(), + scope_cap_lines: z.number().int().nullable(), + }); + +export const GateConfigEffectiveResponseSchema = z + .object({ + repoFullName: z.string(), + effective: z.object({ + confidenceFloor: z.number().nullable(), + scopeCap: z.object({ + files: z.number().int().nullable(), + lines: z.number().int().nullable(), + }), + }), + shadowPending: z.boolean(), + }); + +/** #9303: mirrors selftuneOverrideAuditOutputSchema (src/mcp/server.ts) — audit rows stay z.unknown(), + * listOverrideAudit is the single source of truth for the event fields. */ +export const ClearSelftuneOverrideResponseSchema = z + .object({ + repoFullName: z.string().optional(), + cleared: z.boolean().optional(), + }); + +/** + * Response body for POST /v1/scoring/eligibility-plan. Field-level parity with `GetEligibilityPlanOutput` in @loopover/contract + * (the `loopover_get_eligibility_plan` MCP tool `outputSchema`) in src/mcp/server.ts — #9301. + */ export const EligibilityPlanResponseSchema = z .object({ eligible: z.boolean().optional(), @@ -600,6 +1095,51 @@ export const IssueRagRetrieveResponseSchema = z * Request body for POST /v1/loop/evaluate-escalation. Field-level parity with `EvaluateEscalationInput` in @loopover/contract * (the `loopover_evaluate_escalation` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. */ +export const ValidateLinkedIssueResponseSchema = z + .object({ + status: z.string().optional(), + repoFullName: z.string().optional(), + issueNumber: z.number().optional(), + found: z.boolean().optional(), + multiplierStatus: z.string().optional(), + multiplierWouldApply: z.boolean().optional(), + blockingReason: z.string().optional(), + reasons: z.unknown().optional(), + report: z.unknown().optional(), + }); + +/** + * Request body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with + * `CheckBeforeStartInput` in @loopover/contract (the `loopover_check_before_start` MCP tool `inputSchema`) — #9304. + */ +export const CheckBeforeStartResponseSchema = z + .object({ + status: z.string().optional(), + repoFullName: z.string().optional(), + found: z.boolean().optional(), + claimStatus: z.string().optional(), + duplicateClusterRisk: z.string().optional(), + recommendation: z.string().optional(), + reasons: z.unknown().optional(), + blockers: z.unknown().optional(), + report: z.unknown().optional(), + }); + +/** + * Request body for POST /v1/loop/results-payload. Field-level parity with `BuildResultsPayloadInput` in @loopover/contract + * (the `loopover_build_results_payload` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. + */ +export const BurdenForecastSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + horizonDays: z.union([z.literal(7), z.literal(30)]), + level: z.enum(["low", "medium", "high", "critical"]), + forecast: z.record(z.string(), z.number()), + findings: z.array(FindingSchema), + summary: z.string(), + }); + export const RoleContextSchema = z .object({ login: z.string(), @@ -681,6 +1221,49 @@ export const ContributorOutcomeHistorySchema = z summary: z.string(), }); +export const RepoOutcomeEvidenceCompletenessSchema = z + .object({ + pullRequestsAnalyzed: z.number(), + withFileDetail: z.number(), + withReviewDetail: z.number(), + withCheckDetail: z.number(), + filesCompletenessRatio: z.number(), + reviewsCompletenessRatio: z.number(), + checksCompletenessRatio: z.number(), + fullyDecidedWithDetail: z.number(), + status: z.enum(["complete", "partial", "missing"]), + }); + +export const RepoOutcomePatternsSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + lane: z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"]), + primaryLanguage: z.string().nullable(), + sampleSize: z.number(), + totals: z.record(z.string(), z.number()), + outsideContributorMergeRate: z.number(), + maintainerLaneMergeRate: z.number(), + dimensions: z.array(z.record(z.string(), z.unknown())), + successPatterns: z.array(z.record(z.string(), z.unknown())), + riskPatterns: z.array(z.record(z.string(), z.unknown())), + evidenceCompleteness: RepoOutcomeEvidenceCompletenessSchema, + findings: z.array(FindingSchema), + summary: z.string(), + }); + +export const RepoOutcomePatternsResponseSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["snapshot", "computed"]), + repoFullName: z.string(), + generatedAt: z.string(), + ageSeconds: z.number(), + freshness: z.enum(["fresh", "stale"]), + patterns: RepoOutcomePatternsSchema, + dataQuality: z.record(z.string(), z.unknown()).optional(), + }); + export const DecisionPackFreshnessSchema = z.enum(["fresh", "stale", "rebuilding", "missing"]); export const AgentRecommendationOutcomeStateSchema = z.enum(["accepted", "rejected", "ignored", "stale", "merged", "closed", "improved"]); @@ -838,6 +1421,164 @@ export const RepoDecisionResponseSchema = z dataQuality: z.record(z.string(), z.unknown()), }); +export const RepoIntelligenceSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["computed", "snapshot"]), + repoFullName: z.string(), + generatedAt: z.string(), + repo: RepositorySchema.nullable(), + lane: LaneAdviceSchema, + queueHealth: z.record(z.string(), z.unknown()).nullable().optional(), + queueTrends: z.record(z.string(), z.unknown()).nullable().optional(), + collisions: z.record(z.string(), z.unknown()).optional(), + configQuality: z.record(z.string(), z.unknown()).nullable().optional(), + labelAudit: z.record(z.string(), z.unknown()).nullable().optional(), + maintainerLane: z.record(z.string(), z.unknown()).nullable().optional(), + maintainerCutReadiness: z.record(z.string(), z.unknown()).nullable().optional(), + contributorIntakeHealth: z.record(z.string(), z.unknown()).nullable().optional(), + dataQuality: z.record(z.string(), z.unknown()), + burdenForecast: BurdenForecastSchema.optional(), + burdenForecastFreshness: z + .object({ + source: z.enum(["snapshot", "computed"]), + generatedAt: z.string(), + ageSeconds: z.number(), + freshness: z.enum(["fresh", "stale"]), + }) + .optional(), + }); + +export const RegistrationReadinessSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + ready: z.boolean(), + recommendedRegistrationMode: z.enum(["direct_pr", "issue_discovery", "split"]), + issuePolicy: z.enum(["issue_discovery_enabled", "split_pr_and_issue_discovery_enabled", "direct_pr_requires_linked_issue", "direct_pr_no_issue_required"]), + directPrReadiness: z.object({ ready: z.boolean(), reasons: z.array(z.string()) }), + issueDiscoveryReadiness: z.object({ ready: z.boolean(), recommendation: z.enum(["enabled", "recommended", "not_recommended"]), reasons: z.array(z.string()) }), + labelPolicy: z.record(z.string(), z.unknown()), + maintainerCutReadiness: z.record(z.string(), z.unknown()), + testCoverageHealth: z.object({ + status: z.enum(["gate_ready", "gate_unknown"]), + trustedLabelPipelineReady: z.boolean(), + checkRunMode: z.enum(["off", "enabled"]), + requiredGate: z.array(z.string()), + note: z.string(), + warnings: z.array(z.string()), + }), + queueHealth: z.object({ level: z.enum(["low", "medium", "high", "critical"]), burdenScore: z.number(), reviewablePullRequests: z.number(), summary: z.string() }), + contributorIntakeHealth: z.record(z.string(), z.unknown()), + docsCompleteness: z.record(z.string(), z.unknown()), + githubApp: z.object({ + installed: z.boolean(), + publicSurface: z + .enum(["off", "comment_and_label", "comment_only", "label_only"]) + .describe( + "Governs ONLY the PR comment and label -- never the LoopOver Context check (checkRunMode) or the LoopOver Orb Review Agent gate check (reviewCheckMode), which are independent axes by design (#2852). Setting this to \"off\" does NOT silence either check-run.", + ), + commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), + publicAudienceMode: z.enum(["oss_maintainer", "gittensor_only"]), + checkRunMode: z.enum(["off", "enabled"]), + reviewCheckMode: z.enum(["required", "visible", "disabled"]), + autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(), + autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(), + quietByDefault: z.boolean(), + behavior: z.string(), + warnings: z.array(z.string()), + }), + policyReadiness: z + .object({ + repoFullName: z.string(), + source: z.enum(["focus_manifest_policy"]), + previewOnly: z.boolean(), + present: z.boolean(), + publicWarnings: z.array( + z.object({ + code: z.string(), + category: z.enum(["contribution_flow", "direct_pr_policy", "issue_discovery", "validation", "maintainer_burden"]), + severity: z.enum(["info", "warning", "critical"]), + title: z.string(), + detail: z.string(), + action: z.string(), + }), + ), + // Owner-only focus-manifest metadata is intentionally excluded from this broad route. + droppedPublicWarnings: z.array( + z.object({ + code: z.string(), + reason: z.enum(["unsafe_public_text"]), + }), + ), + summary: z.string(), + }) + .nullable(), + onboardingPackPreview: z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + source: z.enum(["policy_compiler"]), + previewOnly: z.literal(true), + publicSafe: z.literal(true), + contributionLanes: z.array( + z.object({ + id: z.string(), + title: z.string(), + summary: z.string(), + preferredPaths: z.array(z.string()), + discouragedPaths: z.array(z.string()), + validationExpectations: z.array(z.string()), + publicNotes: z.array(z.string()), + }), + ), + labelPolicy: z.object({ + preferredLabels: z.array(z.string()), + requiredLabels: z.array(z.string()), + discouragedLabels: z.array(z.string()), + note: z.string().nullable(), + }), + validationExpectations: z.array(z.string()), + readinessWarnings: z.array(z.string()), + maintainerExpectations: z.array(z.string()), + publicOutputBoundaries: z.array(z.string()), + previewMarkdown: z.string(), + droppedPublicItems: z.array( + z.object({ + field: z.string(), + reason: z.enum(["empty", "unsafe_public_text"]), + }), + ), + privateOwnerContext: z.object({ + itemCount: z.number(), + includedInPublicPreview: z.literal(false), + }), + publication: z.object({ + status: z.enum(["preview_only"]), + allowed: z.literal(false), + actions: z.array(z.string()), + reason: z.string(), + }), + }) + .nullable(), + blockers: z.array(z.string()), + warnings: z.array(z.string()), + dataQuality: z.record(z.string(), z.unknown()), + }); + +export const GittensorConfigRecommendationSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + privateOnly: z.boolean(), + current: z.record(z.string(), z.unknown()).nullable(), + recommended: z.record(z.string(), z.unknown()), + tradeoffs: z.array(z.string()), + reasons: z.array(z.string()), + warnings: z.array(z.string()), + dataQuality: z.record(z.string(), z.unknown()), + }); + export const RewardRiskActionSchema = z .object({ actionKind: z.enum([ @@ -1109,6 +1850,87 @@ export const LocalBranchAnalysisSchema = z dataQuality: z.record(z.string(), z.unknown()), }); +export const MaintainerNoiseReportSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + score: z.number(), + level: z.enum(["low", "medium", "high", "critical"]), + noiseSources: z.array(z.string()), + maintainerActions: z.array(z.enum(["review_now", "needs_author", "likely_duplicate", "close_or_redirect", "watch", "maintainer_lane"])), + queueHealth: QueueHealthSchema, + summary: z.string(), + }); + +export const AmsMinerCohortMetricsSchema = z.object({ + submitterCount: z.number(), + prVolume: z.number(), + acceptanceRate: z.number().nullable(), + avgReviewCycleCount: z.number().nullable(), + avgTimeToMergeMs: z.number().nullable(), +}); + +export const AmsMinerCohortComparisonSchema = z + .object({ + present: z.boolean(), + windowDays: z.number(), + totalSubmitterCount: z.number(), + checkedSubmitterCount: z.number(), + amsCohort: AmsMinerCohortMetricsSchema, + humanCohort: AmsMinerCohortMetricsSchema, + }); + +/** + * Response body for GET /v1/repos/{owner}/{repo}/gate-precision. Field-level parity with + * `gatePrecisionOutputSchema` (the `loopover_get_gate_precision` MCP tool `outputSchema`) in + * src/mcp/server.ts — #9302. + */ +export const ActivationPreviewResponseSchema = z + .object({ + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + currentReviewCheckMode: z.string().optional(), + aiReviewConfigured: z.boolean().optional(), + evaluatedCount: z.number().optional(), + withFindingsCount: z.number().optional(), + findingCodeCounts: z.array(z.unknown()).optional(), + samples: z.array(z.unknown()).optional(), + recommendedAction: z.string().nullable().optional(), + summary: z.string().optional(), + }); + +export const PullRequestReviewabilitySchema = z + .object({ + repoFullName: z.string(), + pullNumber: z.number(), + generatedAt: z.string(), + score: z.number(), + action: z.enum(["review_now", "needs_author", "likely_duplicate", "close_or_redirect", "watch", "maintainer_lane"]), + noiseSources: z.array(z.string()), + whyThisHelps: z.array(z.string()), + maintainerNextSteps: z.array(z.string()), + privateSummary: z.string(), + }); + +export const PullRequestAiReviewFindingsSchema = z + .object({ + status: z.enum(["ready", "not_found", "ai_review_off"]), + repoFullName: z.string(), + pullNumber: z.number(), + login: z.string(), + headSha: z.string().nullable().optional(), + findings: z.array( + z.object({ + category: z.string(), + path: z.string(), + severity: z.enum(["blocker", "nit"]), + line: z.number(), + body: z.string(), + }), + ), + categoryCounts: z.record(z.string(), z.number()), + }); + export const RegistryChangeReportSchema = z .object({ generatedAt: z.string(), @@ -1319,14 +2141,36 @@ export const CLI_RESPONSE_SCHEMAS = { * with interpolation, so the match happens at the type level (see MatchApiPath) rather than by key. */ export const CLI_PARAMETERISED_RESPONSE_SCHEMAS = { - "/v1/agent/runs/{id}": AgentRunBundleSchema, - "/v1/bounties/{id}/advisory": BountyAdvisorySchema, - "/v1/contributors/{login}/decision-pack": ContributorDecisionPackSchema, - "/v1/contributors/{login}/notifications": NotificationFeedSchema, - "/v1/contributors/{login}/notifications/read": NotificationsMarkedSchema, - "/v1/contributors/{login}/open-pr-monitor": ContributorOpenPrMonitorSchema, - "/v1/contributors/{login}/profile": ContributorProfileSchema, - "/v1/contributors/{login}/repos/{owner}/{repo}/decision": RepoDecisionResponseSchema, + "DELETE /v1/repos/{owner}/{repo}/selftune/overrides": ClearSelftuneOverrideResponseSchema, + "GET /v1/agent/runs/{id}": AgentRunBundleSchema, + "GET /v1/bounties/{id}/advisory": BountyAdvisorySchema, + "GET /v1/contributors/{login}/decision-pack": ContributorDecisionPackSchema, + "GET /v1/contributors/{login}/notifications": NotificationFeedSchema, + "GET /v1/contributors/{login}/open-pr-monitor": ContributorOpenPrMonitorSchema, + "GET /v1/contributors/{login}/profile": ContributorProfileSchema, + "GET /v1/contributors/{login}/repos/{owner}/{repo}/decision": RepoDecisionResponseSchema, + "GET /v1/repos/{owner}/{repo}/activation-preview": ActivationPreviewResponseSchema, + "GET /v1/repos/{owner}/{repo}/agent/pending-actions": ListPendingActionsResponseSchema, + "GET /v1/repos/{owner}/{repo}/ams-miner-cohort": AmsMinerCohortComparisonSchema, + "GET /v1/repos/{owner}/{repo}/automation-state": AutomationStateSchema, + "GET /v1/repos/{owner}/{repo}/gate-config/effective": GateConfigEffectiveResponseSchema, + "GET /v1/repos/{owner}/{repo}/gittensor-config-recommendation": GittensorConfigRecommendationSchema, + "GET /v1/repos/{owner}/{repo}/intelligence": RepoIntelligenceSchema, + "GET /v1/repos/{owner}/{repo}/issue-quality": IssueQualityResponseSchema, + "GET /v1/repos/{owner}/{repo}/live-gate-thresholds": LiveGateThresholdsResponseSchema, + "GET /v1/repos/{owner}/{repo}/maintainer-noise": MaintainerNoiseReportSchema, + "GET /v1/repos/{owner}/{repo}/outcome-patterns": RepoOutcomePatternsResponseSchema, + "GET /v1/repos/{owner}/{repo}/pulls/{number}/ai-review-findings": PullRequestAiReviewFindingsSchema, + "GET /v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet": PullRequestMaintainerPacketSchema, + "GET /v1/repos/{owner}/{repo}/pulls/{number}/reviewability": PullRequestReviewabilitySchema, + "GET /v1/repos/{owner}/{repo}/registration-readiness": RegistrationReadinessSchema, + "GET /v1/repos/{owner}/{repo}/settings": RepositorySettingsSchema, + "POST /v1/contributors/{login}/notifications/read": NotificationsMarkedSchema, + "POST /v1/repos/{owner}/{repo}/agent/pending-actions": ProposeActionResponseSchema, + "POST /v1/repos/{owner}/{repo}/agent/pending-actions/{id}/{decision}": DecidePendingActionResponseSchema, + "POST /v1/repos/{owner}/{repo}/check-before-start": CheckBeforeStartResponseSchema, + "POST /v1/repos/{owner}/{repo}/repo-docs/refresh": RepoDocRefreshResultSchema, + "POST /v1/repos/{owner}/{repo}/validate-linked-issue": ValidateLinkedIssueResponseSchema, } as const; /** A path the client validates. */ @@ -1335,8 +2179,8 @@ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; /** The parsed response type for a validated path -- what the CLI call sites get instead of `any`. */ export type ApiResponse = z.infer<(typeof CLI_RESPONSE_SCHEMAS)[Path]>; -/** A parameterised path pattern the client validates. */ -export type ParameterisedApiPath = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; +/** A parameterised call the client validates, as `METHOD path`. */ +export type ParameterisedApiCall = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; /** * A pattern with every `{param}` widened to `${string}`, so a concrete path can be matched against it. @@ -1349,14 +2193,18 @@ export type TemplatedApiPath = Pattern extends `${infer : Pattern; /** - * The pattern a CONCRETE path matches, or `never` when it matches none. + * The call a CONCRETE path matches for a given METHOD, or `never` when it matches none. * * This is what lets the CLI keep writing its natural interpolated template and still get the exact response - * type: the mapped type distributes over every known pattern and keeps only the arms the string satisfies. + * type: the mapped type distributes over every known call and keeps only the arms whose method matches AND + * whose pattern the string satisfies. Method-aware because one path can serve two of them with different + * shapes -- `/v1/repos/{owner}/{repo}/agent/pending-actions` lists on GET and proposes on POST. */ -export type MatchApiPath = { - [Pattern in ParameterisedApiPath]: Path extends TemplatedApiPath ? Pattern : never; -}[ParameterisedApiPath]; - -/** The parsed response for a concrete parameterised path. */ -export type ParameterisedApiResponse = z.infer<(typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiPath]>; +export type MatchApiCall = { + [Call in ParameterisedApiCall]: Call extends `${Method} ${infer Pattern}` ? (Path extends TemplatedApiPath ? Call : never) : never; +}[ParameterisedApiCall]; + +/** The parsed response for a concrete parameterised call. */ +export type ParameterisedApiResponse = z.infer< + (typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiCall] +>; diff --git a/packages/loopover-contract/src/limits.ts b/packages/loopover-contract/src/limits.ts index 2549466e79..64e87df132 100644 --- a/packages/loopover-contract/src/limits.ts +++ b/packages/loopover-contract/src/limits.ts @@ -109,3 +109,15 @@ export const PUBLIC_SURFACE_SKIP_REASONS = [ "miner_detection_unavailable", "not_official_gittensor_miner", ] as const; + +/** + * Bounds the API RESPONSE schemas apply (#9773). + * + * Reached the contract when the parameterised-path response tables did: a schema copied by + * gen-contract-api-schemas.ts brings its bounds with it, and this package cannot import the Worker. + * Pinned against their originals like every other entry here. + */ +/** src/types.ts */ +export const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100; +/** src/settings/agent-actions.ts -- keeps the review-nag lookback from overflowing Date arithmetic. */ +export const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index cf5e0ab96a..edaa66236d 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; import { CLI_RESPONSE_SCHEMAS, type ApiResponse, - type MatchApiPath, + type MatchApiCall, type ParameterisedApiResponse, type ValidatedApiPath, } from "@loopover/contract/api-schemas"; @@ -918,7 +918,7 @@ function registerStdioTool( registerStdioTool( "loopover_get_repo_context", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver repo intelligence.", await apiGet(`${prefix}/intelligence`)); }, ); @@ -926,7 +926,7 @@ registerStdioTool( registerStdioTool( "loopover_get_pr_reviewability", async ({ owner, repo, number }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver PR reviewability.", await apiGet(`${prefix}/pulls/${number}/reviewability`)); }, ); @@ -934,7 +934,7 @@ registerStdioTool( registerStdioTool( "loopover_get_pr_maintainer_packet", async ({ owner, repo, number }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver PR maintainer packet.", await apiGet(`${prefix}/pulls/${number}/maintainer-packet`)); }, ); @@ -947,7 +947,7 @@ registerStdioTool( async ({ owner, repo, number, login }: z.infer) => { const authorLogin = login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!authorLogin) throw new Error("No GitHub login: pass `login`, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult( "LoopOver PR AI-review findings.", await apiGet(`${prefix}/pulls/${number}/ai-review-findings?login=${encodeURIComponent(authorLogin)}`), @@ -958,7 +958,7 @@ registerStdioTool( registerStdioTool( "loopover_get_maintainer_noise", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver maintainer noise report.", await apiGet(`${prefix}/maintainer-noise`)); }, ); @@ -972,7 +972,7 @@ registerStdioTool( const query = new URLSearchParams(); if (since !== undefined) query.set("since", String(since)); if (limit !== undefined) query.set("limit", String(limit)); - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult(`LoopOver agent audit feed for ${owner}/${repo}.`, await apiGet(`${prefix}/agent/audit-feed${query.size > 0 ? `?${query}` : ""}`)); }, ); @@ -983,7 +983,7 @@ registerStdioTool( registerStdioTool( "loopover_refresh_repo_docs", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult(`LoopOver repo-doc refresh for ${owner}/${repo}.`, await apiPost(`${prefix}/repo-docs/refresh`, {})); }, ); @@ -991,7 +991,7 @@ registerStdioTool( registerStdioTool( "loopover_get_ams_miner_cohort", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver AMS miner cohort.", await apiGet(`${prefix}/ams-miner-cohort`)); }, ); @@ -1002,7 +1002,7 @@ registerStdioTool( registerStdioTool( "loopover_get_repo_focus_manifest", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver focus manifest.", await apiGet(`${prefix}/focus-manifest`)); }, ); @@ -1014,7 +1014,7 @@ registerStdioTool( registerStdioTool( "loopover_get_repo_onboarding_pack", async ({ owner, repo, refresh }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const query = refresh === true ? "?refresh=true" : ""; return toolResult( `LoopOver onboarding pack preview for ${owner}/${repo} (preview-only, not published).`, @@ -1029,7 +1029,7 @@ registerStdioTool( registerStdioTool( "loopover_get_activation_preview", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver activation preview.", await apiGet(`${prefix}/activation-preview`)); }, ); @@ -1037,7 +1037,7 @@ registerStdioTool( registerStdioTool( "loopover_get_live_gate_thresholds", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver live gate thresholds.", await apiGet(`${prefix}/live-gate-thresholds`)); }, ); @@ -1045,7 +1045,7 @@ registerStdioTool( registerStdioTool( "loopover_get_gate_config_effective", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver effective gate config.", await apiGet(`${prefix}/gate-config/effective`)); }, ); @@ -1053,7 +1053,7 @@ registerStdioTool( registerStdioTool( "loopover_get_issue_quality", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver issue-quality report.", await apiGet(`${prefix}/issue-quality`)); }, ); @@ -1061,7 +1061,7 @@ registerStdioTool( registerStdioTool( "loopover_get_registration_readiness", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver registration-readiness report.", await apiGet(`${prefix}/registration-readiness`)); }, ); @@ -1069,7 +1069,7 @@ registerStdioTool( registerStdioTool( "loopover_get_config_recommendation", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver config recommendation.", await apiGet(`${prefix}/gittensor-config-recommendation`)); }, ); @@ -1106,7 +1106,7 @@ registerStdioTool( registerStdioTool( "loopover_validate_linked_issue", async ({ owner, repo, issueNumber, plannedChange }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const body = { issueNumber, ...(plannedChange ? { plannedChange } : {}) }; return toolResult("LoopOver linked-issue validation.", await apiPost(`${prefix}/validate-linked-issue`, body)); }, @@ -1115,7 +1115,7 @@ registerStdioTool( registerStdioTool( "loopover_check_before_start", async ({ owner, repo, issueNumber, title, plannedPaths }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const body = { ...(issueNumber != null ? { issueNumber } : {}), ...(title ? { title } : {}), @@ -1402,7 +1402,7 @@ registerStdioTool( registerStdioTool( "loopover_get_label_audit", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const intelligence = await apiGet(`${prefix}/intelligence`); return toolResult("LoopOver label audit.", { repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`, @@ -1418,7 +1418,7 @@ registerStdioTool( registerStdioTool( "loopover_get_maintainer_lane", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const intelligence = await apiGet(`${prefix}/intelligence`); return toolResult("LoopOver maintainer lane.", { repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`, @@ -1431,7 +1431,7 @@ registerStdioTool( registerStdioTool( "loopover_get_burden_forecast", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const intelligence = await apiGet(`${prefix}/intelligence`); return toolResult("LoopOver burden forecast.", { repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`, @@ -1447,7 +1447,7 @@ registerStdioTool( registerStdioTool( "loopover_get_repo_outcome_patterns", async ({ owner, repo }: z.infer) => { - const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const prefix: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; return toolResult("LoopOver repo outcome patterns.", await apiGet(`${prefix}/outcome-patterns`)); }, ); @@ -1852,7 +1852,15 @@ registerStdioTool( // from there) -- no new HTTP paths, and no behaviour the CLI doesn't already have. /** `/v1/repos/:owner/:repo` for a tool's owner+repo input, matching maintainCli's own repoBase. */ -function toolRepoBase(owner: string, repo: string) { +/** + * The per-repo base path (#9773). + * + * The return type is a TEMPLATE-LITERAL type, not `string`. That is what lets `apiGet(`${toolRepoBase(o, r)}/settings`)` + * still resolve to `/v1/repos/{owner}/{repo}/settings` and pick up that route's published response schema -- + * a plain `string` return erases the path at the type level, which is why every payload built on this helper + * fell through to the untyped overload. + */ +function toolRepoBase(owner: string, repo: string): `/v1/repos/${string}/${string}` { return `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; } @@ -2734,7 +2742,7 @@ export async function maintainCli(args: readonly string[]) { // The defaults never fire: the guard proved the string contains a "/". They exist because // noUncheckedIndexedAccess types a destructured split as possibly-undefined. const [owner = "", repo = ""] = repoFullName.split("/", 2); - const repoBase = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const repoBase: `/v1/repos/${string}/${string}` = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const queueBase = `${repoBase}/agent/pending-actions`; const emit = (payload: unknown, line: string) => { if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -3672,7 +3680,7 @@ async function notificationsCli(options: CliOptions) { // loopover_watch_issues stdio tool so there is no duplicated HTTP logic. action maps list=GET, watch=POST, // unwatch=DELETE on the /v1/contributors/:login/watches route family (the same routes the CLI already hit). function watchIssuesRequest(login: string, action: any, repoFullName?: any, labels?: any) { - const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; + const base: `/v1/contributors/${string}/watches` = `/v1/contributors/${encodeURIComponent(login)}/watches`; if (action === "watch") return apiPost(base, { repoFullName, ...(labels && labels.length > 0 ? { labels } : {}) }); if (action === "unwatch") return apiDelete(base, { repoFullName }); return apiGet(base); @@ -5840,14 +5848,14 @@ async function apiGet(path: Path): Promise(path: MatchApiPath extends never ? never : Path): Promise>; +async function apiGet(path: MatchApiCall<"GET", Path> extends never ? never : Path): Promise>; async function apiGet(path: string): Promise; async function apiGet(path: string) { return apiFetch(path, { method: "GET" }); } async function apiPost(path: Path, body: unknown): Promise>; -async function apiPost(path: MatchApiPath extends never ? never : Path, body: unknown): Promise>; +async function apiPost(path: MatchApiCall<"POST", Path> extends never ? never : Path, body: unknown): Promise>; async function apiPost(path: string, body: unknown): Promise; async function apiPost(path: string, body: unknown) { return apiFetch(path, { method: "POST", body: JSON.stringify(body) }); diff --git a/scripts/gen-contract-api-schemas.ts b/scripts/gen-contract-api-schemas.ts index 5b2d2ab9ee..d960110617 100644 --- a/scripts/gen-contract-api-schemas.ts +++ b/scripts/gen-contract-api-schemas.ts @@ -54,15 +54,47 @@ export function cliApiPaths(binSource: string): string[] { * braces and a `?:` with slashes in both arms), then the segments are re-keyed positionally against the * document's parameter names, so the emitted key is exactly the string `openapi.json` uses. */ -export function cliParameterisedApiPaths(binSource: string, document: OpenApiDocument): string[] { +/** + * The path SHAPES the bin's own declarations announce, by name (#9773). + * + * The CLI composes most of its per-repo calls on a base it built earlier -- `` `${repoBase}/settings` `` -- + * so a scanner reading the call site alone sees a template starting with an interpolation and can resolve + * nothing. Those bases now carry a template-literal ANNOTATION (`const repoBase: \`/v1/repos/\${string}/\${string}\``) + * and the helper that builds them declares the same as its return type, because the type checker needs that + * to resolve the response schema. This reads the same annotations, so the scanner and the type system agree + * about what a base is rather than each guessing. + */ +export function declaredPathShapes(binSource: string): Map { + const shapes = new Map(); + for (const match of binSource.matchAll(/\bconst ([A-Za-z0-9_]+): `(\/v1\/[^`]*)`\s*=/g)) shapes.set(match[1]!, match[2]!); + for (const match of binSource.matchAll(/\bfunction ([A-Za-z0-9_]+)\([^)]*\): `(\/v1\/[^`]*)`/g)) shapes.set(`${match[1]!}()`, match[2]!); + return shapes; +} + +/** + * The parameterised calls the CLI makes, as `METHOD path` (#9773). + * + * Keyed by METHOD, not by path alone. `/v1/repos/{owner}/{repo}/agent/pending-actions` is called with GET + * to list the queue and POST to propose an action, and those return different shapes -- a path-keyed table + * had to guess between them, and guessing "post" handed the GET call site the POST response type. + */ +export function cliParameterisedApiCalls(binSource: string, document: OpenApiDocument): string[] { const documented = Object.keys(document.paths).filter((path) => path.includes("{")); const shapes = new Map(); for (const documentPath of documented) shapes.set(documentPath.replace(/\{[^}]+\}/g, "{}"), documentPath); + const declared = declaredPathShapes(binSource); const found = new Set(); - for (const match of binSource.matchAll(/api(?:Get|Post|Delete|Fetch)\(\s*`(\/v1\/[^`]*)`/g)) { - const raw = match[1]!; - if (!raw.includes("${")) continue; + const METHOD_BY_HELPER: Record = { apiGet: "GET", apiPost: "POST", apiDelete: "DELETE" }; + // A leading `${base}` or `${helper(...)}` is replaced with that declaration's own shape before collapsing, + // so a composed call resolves to the same path the type checker resolves it to. + const resolveLead = (template: string): string => + template.replace(/^\$\{\s*([A-Za-z0-9_]+)\s*(\([^)]*\))?\s*\}/, (whole, name: string, call?: string) => declared.get(call ? `${name}()` : name) ?? whole); + + for (const match of binSource.matchAll(/\b(apiGet|apiPost|apiDelete)\(\s*`([^`]*)`/g)) { + const method = METHOD_BY_HELPER[match[1]!]!; + const raw = resolveLead(match[2]!); + if (!raw.startsWith("/v1/") || !raw.includes("${")) continue; // Collapse each interpolation, honouring nested braces, then drop any trailing query the template adds. let collapsed = ""; for (let index = 0; index < raw.length; index += 1) { @@ -84,16 +116,36 @@ export function cliParameterisedApiPaths(binSource: string, document: OpenApiDoc // A template whose interpolation spans a slash (a conditional query suffix, say) cannot be a path // shape; it simply will not match a documented one, and is left unvalidated exactly as before. const documentPath = shapes.get(withoutQuery); - if (documentPath) found.add(documentPath); + if (documentPath) found.add(`${method} ${documentPath}`); } return [...found].sort(); } +/** `METHOD path` -> the schema THAT METHOD's 200 names, for the calls the CLI actually makes. */ +export function responseSchemaByCall(document: OpenApiDocument, calls: readonly string[]): Map { + const byCall = new Map(); + for (const call of calls) { + const [method, path] = call.split(" ") as [string, string]; + const operation = (document.paths[path]?.[method.toLowerCase()] ?? {}) as { + responses?: Record }>; + }; + const ref = operation.responses?.["200"]?.content?.["application/json"]?.schema?.$ref; + if (ref) byCall.set(call, `${ref.split("/").pop()}Schema`); + } + return byCall; +} + type SchemaBlock = { name: string; source: string; exported: boolean }; -/** Every top-level `const XSchema = ...` in the source, in declaration order, with its full body. */ +/** + * Every top-level `const` in the source, in declaration order, with its full body. + * + * Not just `XSchema`: a schema routinely references a plain value declared beside it + * (`const AGENT_ACTION_CLASS_VALUES = [...] as const`), and a copy that carried the schema but not that + * value would emit a file that does not compile. `closure` decides which of these are actually reachable. + */ export function parseSchemaBlocks(source: string): SchemaBlock[] { - const declarations = [...source.matchAll(/^(export )?const ([A-Za-z0-9_]+Schema)\s*=/gm)]; + const declarations = [...source.matchAll(/^(export )?const ([A-Za-z0-9_]+)\s*[:=]/gm)]; return declarations.map((declaration, index) => { const start = declaration.index!; const end = index + 1 < declarations.length ? declarations[index + 1]!.index! : source.length; @@ -130,7 +182,9 @@ export function closure(blocks: SchemaBlock[], roots: readonly string[]): Schema const queue = [...reached]; while (queue.length > 0) { const block = byName.get(queue.pop()!)!; - for (const match of block.source.matchAll(/\b([A-Za-z0-9_]+Schema)\b/g)) { + // Any declared name, not just `*Schema`: a schema's dependency can be a plain value declared beside it, + // and following only Schema-suffixed references is what left those behind (#9773). + for (const match of block.source.matchAll(/\b([A-Za-z0-9_]+)\b/g)) { const dependency = match[1]!; if (dependency !== block.name && byName.has(dependency) && !reached.has(dependency)) { reached.add(dependency); @@ -141,6 +195,33 @@ export function closure(blocks: SchemaBlock[], roots: readonly string[]): Schema return blocks.filter((block) => reached.has(block.name)); } +/** + * The bounds a copied schema references but the copy does not define (#9773). + * + * `closure` follows schema-to-schema references; a schema also referencing a plain constant + * (`MAX_CONTRIBUTOR_OPEN_ITEM_CAP`) would emit a file that does not compile. Those constants live in + * @loopover/contract's own limits.ts, restated and pinned there, so the generator imports them -- and a + * constant that has NOT been added there fails the contract build, which is the loud failure this wants. + */ +export function referencedLimits(blocks: SchemaBlock[]): string[] { + const defined = new Set(blocks.map((block) => block.name)); + const referenced = new Set(); + for (const block of blocks) { + // Comments and string literals FIRST. Without stripping them, prose picks up every capitalised word a + // doc comment happens to contain ("DELETE", "REQUIRED", "REST") and emits an import for each. + const code = block.source + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/\/\/[^\n]*/g, " ") + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/'(?:[^'\\]|\\.)*'/g, "''"); + for (const match of code.matchAll(/\b([A-Z][A-Z0-9_]{3,})\b/g)) { + const name = match[1]!; + if (!defined.has(name)) referenced.add(name); + } + } + return [...referenced].sort(); +} + const HEADER = `// GENERATED by scripts/gen-contract-api-schemas.ts -- do not edit. // // The API response schemas packages/loopover-mcp validates against (#9521). Copied from @@ -156,8 +237,9 @@ import { z } from "zod"; export function renderApiSchemas(sourceText: string, documentText: string, binSource: string): string { const document = JSON.parse(documentText) as OpenApiDocument; const byPath = responseSchemaByPath(document, cliApiPaths(binSource)); - const byPattern = responseSchemaByPath(document, cliParameterisedApiPaths(binSource, document)); + const byPattern = responseSchemaByCall(document, cliParameterisedApiCalls(binSource, document)); const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values()])]); + const limits = referencedLimits(blocks); const body = blocks .map((block) => block.source @@ -174,7 +256,8 @@ export function renderApiSchemas(sourceText: string, documentText: string, binSo .sort(([left], [right]) => left.localeCompare(right)) .map(([path, schema]) => ` "${path}": ${schema},`) .join("\n"); - return `${HEADER}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${PATTERN_TABLE_HEADER}${patternTable}\n} as const;\n\n${TABLE_TYPES}`; + const limitsImport = limits.length > 0 ? `import { ${limits.join(", ")} } from "./limits.js";\n\n` : ""; + return `${HEADER}${limitsImport}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${PATTERN_TABLE_HEADER}${patternTable}\n} as const;\n\n${TABLE_TYPES}`; } const TABLE_HEADER = `/** @@ -202,8 +285,8 @@ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; /** The parsed response type for a validated path -- what the CLI call sites get instead of \`any\`. */ export type ApiResponse = z.infer<(typeof CLI_RESPONSE_SCHEMAS)[Path]>; -/** A parameterised path pattern the client validates. */ -export type ParameterisedApiPath = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; +/** A parameterised call the client validates, as \`METHOD path\`. */ +export type ParameterisedApiCall = keyof typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS; /** * A pattern with every \`{param}\` widened to \`\${string}\`, so a concrete path can be matched against it. @@ -216,17 +299,21 @@ export type TemplatedApiPath = Pattern extends \`\${infe : Pattern; /** - * The pattern a CONCRETE path matches, or \`never\` when it matches none. + * The call a CONCRETE path matches for a given METHOD, or \`never\` when it matches none. * * This is what lets the CLI keep writing its natural interpolated template and still get the exact response - * type: the mapped type distributes over every known pattern and keeps only the arms the string satisfies. + * type: the mapped type distributes over every known call and keeps only the arms whose method matches AND + * whose pattern the string satisfies. Method-aware because one path can serve two of them with different + * shapes -- \`/v1/repos/{owner}/{repo}/agent/pending-actions\` lists on GET and proposes on POST. */ -export type MatchApiPath = { - [Pattern in ParameterisedApiPath]: Path extends TemplatedApiPath ? Pattern : never; -}[ParameterisedApiPath]; +export type MatchApiCall = { + [Call in ParameterisedApiCall]: Call extends \`\${Method} \${infer Pattern}\` ? (Path extends TemplatedApiPath ? Call : never) : never; +}[ParameterisedApiCall]; -/** The parsed response for a concrete parameterised path. */ -export type ParameterisedApiResponse = z.infer<(typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiPath]>; +/** The parsed response for a concrete parameterised call. */ +export type ParameterisedApiResponse = z.infer< + (typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiCall] +>; `; export function generate(deps: { readFile?: (path: string) => string } = {}): string { diff --git a/test/unit/mcp-api-client.test.ts b/test/unit/mcp-api-client.test.ts index 2dcd5cade8..280a824c30 100644 --- a/test/unit/mcp-api-client.test.ts +++ b/test/unit/mcp-api-client.test.ts @@ -2,7 +2,16 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { CLI_RESPONSE_SCHEMAS } from "@loopover/contract/api-schemas"; -import { cliApiPaths, responseSchemaByPath } from "../../scripts/gen-contract-api-schemas"; +import { + cliApiPaths, + cliParameterisedApiCalls, + closure, + declaredPathShapes, + parseSchemaBlocks, + referencedLimits, + responseSchemaByCall, + responseSchemaByPath, +} from "../../scripts/gen-contract-api-schemas"; // #9521: the typed validated API client. The stdio CLI used to read every response as `payload: any`; // these pin the three properties that keep that from coming back: the literal is extinct, the validated @@ -99,3 +108,49 @@ describe("responseSchemaByPath", () => { expect(byPath.size).toBe(0); }); }); + +// #9773: the PARAMETERISED half. Every per-repo and per-contributor call the CLI makes used to miss the +// typed overload, because the scanner rejected any template containing an interpolation -- so those payloads +// read as `any` while the published document described most of them precisely. +describe("parameterised response schemas (#9773)", () => { + const document = JSON.parse(readFileSync(join(process.cwd(), "apps/loopover-ui/public/openapi.json"), "utf8")) as Parameters[0]; + const bin = readFileSync(join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.ts"), "utf8"); + + it("keys by METHOD, because one path can serve two of them with different shapes", () => { + // /v1/repos/{owner}/{repo}/agent/pending-actions LISTS on GET and PROPOSES on POST. A path-keyed table + // had to guess between them, and guessing handed the GET call site the POST response type -- which the + // CLI's own `payload.pendingActions` read then contradicted the moment a schema was attached at all. + const calls = cliParameterisedApiCalls(bin, document); + expect(calls).toContain("GET /v1/repos/{owner}/{repo}/agent/pending-actions"); + expect(calls).toContain("POST /v1/repos/{owner}/{repo}/agent/pending-actions"); + + const byCall = responseSchemaByCall(document, calls); + expect(byCall.get("GET /v1/repos/{owner}/{repo}/agent/pending-actions")).toBe("ListPendingActionsResponseSchema"); + expect(byCall.get("POST /v1/repos/{owner}/{repo}/agent/pending-actions")).toBe("ProposeActionResponseSchema"); + }); + + it("resolves a call composed on a declared base path", () => { + // The CLI writes `${repoBase}/settings`, never the whole literal. The scanner and the type checker both + // read the base's declared template-literal shape, so they cannot disagree about what that base is. + expect(declaredPathShapes(bin).get("toolRepoBase()")).toBe("/v1/repos/${string}/${string}"); + expect(cliParameterisedApiCalls(bin, document)).toContain("GET /v1/repos/{owner}/{repo}/settings"); + }); + + it("covers substantially more than the eight paths written out in full", () => { + expect(cliParameterisedApiCalls(bin, document).length).toBeGreaterThan(20); + }); + + it("carries a bound a copied schema references rather than emitting a file that cannot compile", () => { + // `closure` follows schema-to-schema references; a schema referencing a plain constant needs that too. + // Anything declared in the source is copied, anything else is imported from limits.ts where it is + // restated and pinned -- and a constant missing there fails the contract build, loudly. + const blocks = closure(parseSchemaBlocks(readFileSync(join(process.cwd(), "src/openapi/schemas.ts"), "utf8")), ["AutomationStateSchema", "RepositorySettingsSchema"]); + expect(blocks.some((block) => block.name === "AGENT_ACTION_CLASS_VALUES"), "a referenced value is copied").toBe(true); + expect(referencedLimits(blocks), "one declared elsewhere is imported").toContain("MAX_REVIEW_NAG_COOLDOWN_DAYS"); + }); + + it("does not mistake a capitalised word in prose for a constant", () => { + // The first cut scanned comments too and emitted an import for DELETE, REQUIRED, REST and friends. + expect(referencedLimits([{ name: "XSchema", exported: true, source: '// DELETE and REQUIRED and REST\nconst XSchema = z.string();' }])).toEqual([]); + }); +}); From 6cd6fa3f66b82042e3243b37a2ffe54df0da57da Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:43:39 -0700 Subject: [PATCH 3/4] types(mcp): type what the CLI SENDS, and correct the four request schemas that lied (#9773) A review found a contributor login being sent to the API as boolean `true` -- a bare `--login` that my sweep had missed. The first answer was a test that grepped the source for the shape; that is a guard against one spelling, not against the defect, so it is gone. The defect is now a compile error. `apiPost`'s body is typed from the request schemas the published document names, so an option value -- `string | boolean | string[]`, because a bare flag is `true` -- cannot reach a field the API declares as a string. Verified by reverting one fix and watching tsc say `Type 'boolean' is not assignable to type 'string'`. Making the types BINDING mattered as much as adding them. The fallback overloads accepted `path: string, body: unknown`, so a call that failed a typed overload did not error -- it fell through and was accepted unchecked. They now refuse any path the typed overloads cover. That found three more instances of the reviewer's class, each in a different command: `lint-pr-text` sent `--body` as `true`, `check-slop-risk` sent `--description` as `true`, and `validate-focus-manifest` sent `--source` as an unchecked free string where the API takes three literals -- its `.includes()` guard never narrowed, so the body kept the raw value. The last is now parsed against the contract's own enum, so the accepted values and the error naming them come from the schema the route validates with. And four published request schemas were wrong. ValidateLinkedIssueRequest required `owner` and `repo` in the BODY though both are path params; CheckSlopRiskRequest required `changedFiles` the handler has optional; ValidateFocusManifestRequest typed an enum as a free string. They were hand-written parallels of the schemas the handlers actually parse with, and they had drifted -- so they are now built from those schemas. Rebuilt via `z.object(shape)` rather than used directly, because `.openapi()` exists only after `extendZodWithOpenApi` and the contract must never run it. The generator carries what a copied schema references, resolved against what each module really exports: bounds from limits.ts, request schemas from api-requests.ts. A name in neither fails the contract build instead of emitting a dangling reference. --- apps/loopover-ui/public/openapi.json | 86 +++++----- packages/loopover-contract/src/api-schemas.ts | 147 ++++++++++++++++++ packages/loopover-mcp/bin/loopover-mcp.ts | 57 ++++--- scripts/gen-contract-api-schemas.ts | 128 ++++++++++++++- src/openapi/schemas.ts | 78 +++++----- 5 files changed, 391 insertions(+), 105 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 50b8e25760..142b6e58f7 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -15850,40 +15850,52 @@ "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 400 }, "additions": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "deletions": { - "type": "integer" + "type": "integer", + "minimum": 0 } }, "required": [ "path" ] - } + }, + "maxItems": 2000 }, "description": { - "type": "string" + "type": "string", + "maxLength": 20000 }, "tests": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 400 + }, + "maxItems": 2000 }, "testFiles": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 400 + }, + "maxItems": 2000 }, "commitMessages": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 2000 + }, + "maxItems": 200 }, "hasLinkedIssue": { "type": "boolean" @@ -15891,10 +15903,7 @@ "issueDiscoveryLane": { "type": "boolean" } - }, - "required": [ - "changedFiles" - ] + } }, "CheckSlopRiskResponse": { "type": "object", @@ -16198,7 +16207,8 @@ "type": "object", "properties": { "content": { - "type": "string" + "type": "string", + "maxLength": 262144 }, "source": { "type": "string", @@ -16269,12 +16279,6 @@ "ValidateLinkedIssueRequest": { "type": "object", "properties": { - "owner": { - "type": "string" - }, - "repo": { - "type": "string" - }, "issueNumber": { "type": "integer", "minimum": 0, @@ -16284,23 +16288,27 @@ "type": "object", "properties": { "title": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 300 }, "changedFiles": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 300 + }, + "maxItems": 200 }, "contributorLogin": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 100 } } } }, "required": [ - "owner", - "repo", "issueNumber" ] }, @@ -16339,31 +16347,25 @@ "CheckBeforeStartRequest": { "type": "object", "properties": { - "owner": { - "type": "string" - }, - "repo": { - "type": "string" - }, "issueNumber": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, "title": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 300 }, "plannedPaths": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 300 + }, + "maxItems": 200 } - }, - "required": [ - "owner", - "repo" - ] + } }, "CheckBeforeStartResponse": { "type": "object", diff --git a/packages/loopover-contract/src/api-schemas.ts b/packages/loopover-contract/src/api-schemas.ts index 1b87e8c694..d44a1f9fbc 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -8,6 +8,7 @@ // boundary has no other way to share a composed schema's parts. import { z } from "zod"; +import { checkBeforeStartSchema, slopRiskSchema, validateFocusManifestSchema, validateLinkedIssueSchema } from "./api-requests.js"; import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "./limits.js"; export const FindingSchema = z @@ -728,6 +729,17 @@ export const ListPendingActionsResponseSchema = z // Mirrors `ProposeActionInput` in @loopover/contract minus owner/repo (both are path params on the REST route), matching the request // body proposePendingActionSchema already validates in src/api/routes.ts. +export const ProposeActionRequestSchema = z + .object({ + pullNumber: z.number().int().positive(), + actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]), + reason: z.string().max(500).optional(), + label: z.string().min(1).max(100).optional(), + reviewBody: z.string().max(60000).optional(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + closeComment: z.string().max(60000).optional(), + }); + export const ProposeActionResponseSchema = z .object({ created: z.boolean().optional(), @@ -1095,6 +1107,12 @@ export const IssueRagRetrieveResponseSchema = z * Request body for POST /v1/loop/evaluate-escalation. Field-level parity with `EvaluateEscalationInput` in @loopover/contract * (the `loopover_evaluate_escalation` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. */ +export const ValidateLinkedIssueRequestSchema = z.object(validateLinkedIssueSchema.shape); + +/** + * Response body for POST /v1/repos/{owner}/{repo}/validate-linked-issue. Field-level parity with + * `ValidateLinkedIssueOutput` in @loopover/contract (the `loopover_validate_linked_issue` MCP tool `outputSchema`) — #9304. + */ export const ValidateLinkedIssueResponseSchema = z .object({ status: z.string().optional(), @@ -1112,6 +1130,20 @@ export const ValidateLinkedIssueResponseSchema = z * Request body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with * `CheckBeforeStartInput` in @loopover/contract (the `loopover_check_before_start` MCP tool `inputSchema`) — #9304. */ +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const CheckBeforeStartRequestSchema = z.object(checkBeforeStartSchema.shape); + +/** + * Response body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with + * `CheckBeforeStartOutput` in @loopover/contract (the `loopover_check_before_start` MCP tool `outputSchema`) — #9304. + */ export const CheckBeforeStartResponseSchema = z .object({ status: z.string().optional(), @@ -2037,6 +2069,21 @@ export const AgentRunBundleSchema = z summary: z.string(), }); +export const ChangedFileSchema = z.object({ + path: z.string(), + additions: z.number().int().optional(), + deletions: z.number().int().optional(), +}); + +/** Request body for POST /v1/lint/pr-text — parity with `lintPrTextShape` (loopover_lint_pr_text). */ +export const LintPrTextRequestSchema = z + .object({ + commitMessages: z.array(z.string()).optional(), + prBody: z.string().optional(), + linkedIssue: z.number().int().positive().optional(), + }); + +/** Response body for POST /v1/lint/pr-text — parity with `lintPrTextOutputSchema`. */ export const LintPrTextResponseSchema = z .object({ verdict: z.string().optional(), @@ -2048,6 +2095,17 @@ export const LintPrTextResponseSchema = z }); /** Request body for POST /v1/lint/slop-risk — parity with `checkSlopRiskShape` (loopover_check_slop_risk). */ +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const CheckSlopRiskRequestSchema = z.object(slopRiskSchema.shape); + +/** Response body for POST /v1/lint/slop-risk — parity with `checkSlopRiskOutputSchema`. */ export const CheckSlopRiskResponseSchema = z .object({ slopRisk: z.number().optional(), @@ -2057,6 +2115,17 @@ export const CheckSlopRiskResponseSchema = z }); /** Request body for POST /v1/lint/improvement-potential — parity with `checkImprovementPotentialShape`. */ +export const CheckImprovementPotentialRequestSchema = z + .object({ + changedFiles: z.array(ChangedFileSchema).optional(), + tests: z.array(z.string()).optional(), + testFiles: z.array(z.string()).optional(), + patchCoverageDeltaPercent: z.number().optional(), + complexityDeltas: z.array(z.unknown()).optional(), + duplicationDeltas: z.array(z.unknown()).optional(), + }); + +/** Response body for POST /v1/lint/improvement-potential — parity with `checkImprovementPotentialOutputSchema`. */ export const CheckImprovementPotentialResponseSchema = z .object({ improvementScore: z.number().optional(), @@ -2065,6 +2134,16 @@ export const CheckImprovementPotentialResponseSchema = z }); /** Request body for POST /v1/lint/open-pr-pressure — parity with `simulateOpenPrPressureShape`. */ +export const SimulateOpenPrPressureRequestSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + queueHealth: z.unknown(), + roleContext: z.object({ maintainerLane: z.boolean() }), + contributorOpenPrCount: z.number().int().optional(), + }); + +/** Response body for POST /v1/lint/open-pr-pressure — parity with `SimulateOpenPrPressureOutput` in @loopover/contract. */ export const SimulateOpenPrPressureResponseSchema = z .object({ repoFullName: z.string().optional(), @@ -2077,6 +2156,15 @@ export const SimulateOpenPrPressureResponseSchema = z }); /** Request body for POST /v1/lint/boundary-tests — parity with `suggestBoundaryTestsShape`. */ +export const SuggestBoundaryTestsRequestSchema = z + .object({ + changedFiles: z.array(z.object({ path: z.string() })), + boundaryTouches: z.array(z.unknown()).optional(), + tests: z.array(z.string()).optional(), + testFiles: z.array(z.string()).optional(), + }); + +/** Response body for POST /v1/lint/boundary-tests — parity with `suggestBoundaryTestsOutputSchema`. */ export const SuggestBoundaryTestsResponseSchema = z .object({ finding: z.unknown().optional(), @@ -2084,6 +2172,16 @@ export const SuggestBoundaryTestsResponseSchema = z }); /** Request body for POST /v1/lint/test-evidence — parity with `checkTestEvidenceShape`. */ +export const CheckIssueSlopRequestSchema = z + .object({ + title: z.string().optional(), + body: z.string().optional(), + }); + +/** + * Response body for POST /v1/lint/issue-slop — parity with `checkIssueSlopOutputSchema`, which is an alias of + * `checkSlopRiskOutputSchema` in src/mcp/server.ts, so the keys match CheckSlopRiskResponse. + */ export const CheckIssueSlopResponseSchema = z .object({ slopRisk: z.number().optional(), @@ -2093,6 +2191,17 @@ export const CheckIssueSlopResponseSchema = z }); /** Request body for POST /v1/validate/focus-manifest — parity with `ValidateConfigInput` in @loopover/contract (loopover_validate_config). */ +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const ValidateFocusManifestRequestSchema = z.object(validateFocusManifestSchema.shape); + +/** Response body for POST /v1/validate/focus-manifest — parity with `ValidateConfigOutput` in @loopover/contract. */ export const ValidateFocusManifestResponseSchema = z .object({ present: z.boolean().optional(), @@ -2173,6 +2282,26 @@ export const CLI_PARAMETERISED_RESPONSE_SCHEMAS = { "POST /v1/repos/{owner}/{repo}/validate-linked-issue": ValidateLinkedIssueResponseSchema, } as const; +/** + * What the CLI must SEND, per call (#9773) -- keyed `METHOD path`, patterns and literals alike. + * + * A call absent here has no named request schema in the document and keeps an unchecked body, exactly as + * before. Adding one is a matter of declaring `request.body` on that route's spec; the schemas already + * live in @loopover/contract/api-requests. + */ +export const CLI_REQUEST_SCHEMAS = { + "POST /v1/lint/boundary-tests": SuggestBoundaryTestsRequestSchema, + "POST /v1/lint/improvement-potential": CheckImprovementPotentialRequestSchema, + "POST /v1/lint/issue-slop": CheckIssueSlopRequestSchema, + "POST /v1/lint/open-pr-pressure": SimulateOpenPrPressureRequestSchema, + "POST /v1/lint/pr-text": LintPrTextRequestSchema, + "POST /v1/lint/slop-risk": CheckSlopRiskRequestSchema, + "POST /v1/repos/{owner}/{repo}/agent/pending-actions": ProposeActionRequestSchema, + "POST /v1/repos/{owner}/{repo}/check-before-start": CheckBeforeStartRequestSchema, + "POST /v1/repos/{owner}/{repo}/validate-linked-issue": ValidateLinkedIssueRequestSchema, + "POST /v1/validate/focus-manifest": ValidateFocusManifestRequestSchema, +} as const; + /** A path the client validates. */ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; @@ -2204,6 +2333,24 @@ export type MatchApiCall = { [Call in ParameterisedApiCall]: Call extends `${Method} ${infer Pattern}` ? (Path extends TemplatedApiPath ? Call : never) : never; }[ParameterisedApiCall]; +/** A call whose request body the client type-checks. */ +export type RequestCheckedApiCall = keyof typeof CLI_REQUEST_SCHEMAS; + +/** The call a concrete METHOD + path matches in the request table, or `never`. */ +export type MatchRequestCall = { + [Call in RequestCheckedApiCall]: Call extends `${Method} ${infer Pattern}` ? (Path extends TemplatedApiPath ? Call : never) : never; +}[RequestCheckedApiCall]; + +/** + * The body a concrete call must send, or `unknown` when the document does not describe one. + * + * `unknown` rather than `never` for the undescribed case: an unchecked body must still be ACCEPTED -- + * this narrows what it can be where the contract knows, and gets out of the way where it does not. + */ +export type ApiRequestBody = MatchRequestCall extends never + ? unknown + : z.input<(typeof CLI_REQUEST_SCHEMAS)[MatchRequestCall]>; + /** The parsed response for a concrete parameterised call. */ export type ParameterisedApiResponse = z.infer< (typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiCall] diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index edaa66236d..4cca0299dc 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -9,11 +9,13 @@ import { fileURLToPath } from "node:url"; import { CLI_RESPONSE_SCHEMAS, type ApiResponse, + type ApiRequestBody, type MatchApiCall, type ParameterisedApiResponse, type ValidatedApiPath, } from "@loopover/contract/api-schemas"; import type { LoopoverConfig } from "@loopover/contract/cli-config"; +import { validateFocusManifestSchema } from "@loopover/contract/api-requests"; import { CLIENT_HOSTS, CLIENT_HOST_SPEC, @@ -3155,7 +3157,7 @@ async function reviewPrCli(options: CliOptions) { if (options.help === true) return printReviewPrHelp(); const contributorLogin = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; if (!contributorLogin) throw new Error("Pass --login or set LOOPOVER_LOGIN."); - let prBody = options.body; + let prBody = optionText(options.body); if (options.bodyFile) prBody = readCliTextFile(optionText(options.bodyFile) ?? "", "Body"); const commitMessages = Array.isArray(options.commit) ? options.commit : options.commit ? [options.commit] : undefined; const linkedIssue = parsePositiveIntegerOption(options.linkedIssue, "--linked-issue"); @@ -3236,7 +3238,7 @@ async function lintPrTextCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printLintPrTextHelp(); const options = parseOptions(args); const commitMessages = Array.isArray(options.commit) ? options.commit : options.commit ? [options.commit] : undefined; - let prBody = options.body; + let prBody = optionText(options.body); if (options.bodyFile) { prBody = readCliTextFile(optionText(options.bodyFile) ?? "", "Body"); } @@ -3291,10 +3293,15 @@ async function validateConfigCli(args: readonly string[]) { const options = parseOptions(args); if (!options.file) throw new Error("Pass --file to the manifest to validate."); const content = readCliTextFile(optionText(options.file) ?? "", "Manifest"); - const source = options.source; - if (source !== undefined && !["repo_file", "api_record", "none"].includes(String(source))) { - throw new Error("--source must be one of: repo_file, api_record, none"); - } + // #9773: parsed against the CONTRACT's own enum rather than a restated list, so the accepted values and + // the error naming them both come from the schema the route validates with -- and the parsed result + // narrows, which a `.includes()` guard never did. That is why the request body could carry a free string + // where the API accepts three literals. + const parsedSource = validateFocusManifestSchema.shape.source.safeParse(optionText(options.source)); + if (!parsedSource.success) { + throw new Error(`--source must be one of: ${validateFocusManifestSchema.shape.source.unwrap().options.join(", ")}`); + } + const source = parsedSource.data; const payload = await apiPost("/v1/validate/focus-manifest", { content, ...(source !== undefined ? { source } : {}), @@ -3346,7 +3353,7 @@ function parseChangedFileSpec(raw: any) { async function slopRiskCli(args: readonly string[]) { if (!args.length || args[0] === "--help" || args[0] === "help") return printSlopRiskHelp(); const options = parseOptions(args); - let description = options.description ?? options.body; + let description = optionText(options.description) ?? optionText(options.body); const descriptionFile = optionText(options.descriptionFile) ?? optionText(options.bodyFile); if (descriptionFile) { description = readCliTextFile(descriptionFile, "Description"); @@ -3611,8 +3618,12 @@ async function explainReviewRiskCli(options: CliOptions) { if (options.help === true) return printExplainReviewRiskHelp(); const repoFullName = optionText(options.repoFullName) ?? optionText(options.repo); if (!repoFullName || !String(repoFullName).includes("/")) throw new Error("Pass --repo owner/repo or --repoFullName owner/repo."); - if (!options.title) throw new Error("Pass --title ."); - const contributorLogin = options.login ?? options.contributorLogin; + const title = optionText(options.title); + if (!title) throw new Error("Pass --title ."); + // #9773: `options.login` unwrapped here sent boolean `true` to the API as the contributor login, and a + // bare `--title` sent `true` as the title -- the same class this sweep fixes everywhere else, missed on + // the first pass because these flow into an untyped request body rather than into a `string` parameter. + const contributorLogin = optionText(options.login) ?? optionText(options.contributorLogin); const labels = Array.isArray(options.label) ? options.label : options.label ? [options.label] : undefined; const changedFiles = Array.isArray(options.changedFile) ? options.changedFile : options.changedFile ? [options.changedFile] : undefined; const linkedIssues = Array.isArray(options.issue) @@ -3625,14 +3636,14 @@ async function explainReviewRiskCli(options: CliOptions) { "/v1/preflight/review-risk", stripUndefined({ repoFullName, - title: options.title, + title, contributorLogin, - body: options.body, + body: optionText(options.body), labels, changedFiles, linkedIssues: linkedIssues && linkedIssues.length > 0 ? linkedIssues : undefined, tests, - authorAssociation: options.authorAssociation, + authorAssociation: optionText(options.authorAssociation), }), ); if (options.json) { @@ -5849,14 +5860,24 @@ async function apiGet(path: Path): Promise(path: MatchApiCall<"GET", Path> extends never ? never : Path): Promise>; -async function apiGet(path: string): Promise; +// The fallback REFUSES a path the typed overloads already cover (#9773). Without that exclusion a call +// whose body or path fails the typed overload does not error -- it silently falls through to here and is +// accepted as `unknown`, which is precisely the hole a typed body was added to close. +async function apiGet(path: Path extends ValidatedApiPath ? never : MatchApiCall<"GET", Path> extends never ? Path : never): Promise; async function apiGet(path: string) { return apiFetch(path, { method: "GET" }); } -async function apiPost(path: Path, body: unknown): Promise>; -async function apiPost(path: MatchApiCall<"POST", Path> extends never ? never : Path, body: unknown): Promise>; -async function apiPost(path: string, body: unknown): Promise; +// #9773: the body is typed too, where the document names a request schema. The CLI assembles bodies from +// parsed options, whose values are `string | boolean | string[]` -- a bare `--login` is boolean `true` -- +// and an `unknown` body accepted every one of them silently, which is how a contributor login came to be +// sent as `true`. Undescribed calls keep an unchecked body rather than being rejected. +async function apiPost(path: Path, body: ApiRequestBody<"POST", Path>): Promise>; +async function apiPost(path: MatchApiCall<"POST", Path> extends never ? never : Path, body: ApiRequestBody<"POST", Path>): Promise>; +async function apiPost( + path: Path extends ValidatedApiPath ? never : MatchApiCall<"POST", Path> extends never ? Path : never, + body: unknown, +): Promise; async function apiPost(path: string, body: unknown) { return apiFetch(path, { method: "POST", body: JSON.stringify(body) }); } @@ -6316,8 +6337,8 @@ function branchEligibilityFromOptions(options: CliOptions) { return stripUndefined({ status, source, - reason: options.branchEligibilityReason, - checkedAt: options.branchEligibilityCheckedAt, + reason: optionText(options.branchEligibilityReason), + checkedAt: optionText(options.branchEligibilityCheckedAt), stale: optionalBoolean(options.branchEligibilityStale), }); } diff --git a/scripts/gen-contract-api-schemas.ts b/scripts/gen-contract-api-schemas.ts index d960110617..bae2086a73 100644 --- a/scripts/gen-contract-api-schemas.ts +++ b/scripts/gen-contract-api-schemas.ts @@ -78,6 +78,16 @@ export function declaredPathShapes(binSource: string): Map { * to list the queue and POST to propose an action, and those return different shapes -- a path-keyed table * had to guess between them, and guessing "post" handed the GET call site the POST response type. */ +/** The LITERAL-path calls the CLI makes, as `METHOD path` -- the request-side twin of cliApiPaths. */ +export function cliApiCalls(binSource: string): string[] { + const calls = new Set(); + const methodByHelper: Record = { apiGet: "GET", apiPost: "POST", apiDelete: "DELETE" }; + for (const match of binSource.matchAll(/\b(apiGet|apiPost|apiDelete)\(\s*(?:"([^"$]*\/v1\/[^"$?]*)(?:\?[^"$]*)?"|`([^`$]*\/v1\/[^`$?]*)(?:\?[^`$]*)?`)/g)) { + calls.add(`${methodByHelper[match[1]!]!} ${(match[2] ?? match[3])!.replace(/\/+$/, "")}`); + } + return [...calls].sort(); +} + export function cliParameterisedApiCalls(binSource: string, document: OpenApiDocument): string[] { const documented = Object.keys(document.paths).filter((path) => path.includes("{")); const shapes = new Map(); @@ -121,6 +131,28 @@ export function cliParameterisedApiCalls(binSource: string, document: OpenApiDoc return [...found].sort(); } +/** + * `METHOD path` -> the schema that method's REQUEST BODY names (#9773). + * + * The response tables stop a call site misreading what came back; this stops it mis-sending what goes out. + * The CLI assembles request bodies from parsed options, whose values are `string | boolean | string[]` -- + * a bare `--login` is boolean `true` -- and `apiPost(path, body: unknown)` accepted every one of them + * silently. A review caught a contributor login being sent as `true`; a typed body makes that a compile + * error instead of something a reviewer has to notice. + */ +export function requestSchemaByCall(document: OpenApiDocument, calls: readonly string[]): Map { + const byCall = new Map(); + for (const call of calls) { + const [method, path] = call.split(" ") as [string, string]; + const operation = (document.paths[path]?.[method.toLowerCase()] ?? {}) as { + requestBody?: { content?: Record }; + }; + const ref = operation.requestBody?.content?.["application/json"]?.schema?.$ref; + if (ref) byCall.set(call, `${ref.split("/").pop()}Schema`); + } + return byCall; +} + /** `METHOD path` -> the schema THAT METHOD's 200 names, for the calls the CLI actually makes. */ export function responseSchemaByCall(document: OpenApiDocument, calls: readonly string[]): Map { const byCall = new Map(); @@ -203,6 +235,42 @@ export function closure(blocks: SchemaBlock[], roots: readonly string[]): Schema * @loopover/contract's own limits.ts, restated and pinned there, so the generator imports them -- and a * constant that has NOT been added there fails the contract build, which is the loud failure this wants. */ +/** + * Names a copied schema references that this file does not define (#9773). + * + * Two kinds, and both must be imported or the copy will not run: bounds, which live in limits.ts, and the + * REQUEST schemas the document's own declarations now re-wrap (`z.object(validateLinkedIssueSchema.shape)`) + * rather than restate -- those live in api-requests.ts. Resolved against what each module actually exports, + * so a name in neither is a loud failure at build time instead of a dangling reference at runtime. + */ +export function referencedExternals(blocks: SchemaBlock[], exportsByModule: ReadonlyMap>): Map { + const defined = new Set(blocks.map((block) => block.name)); + const byModule = new Map(); + for (const name of referencedIdentifiers(blocks)) { + if (defined.has(name)) continue; + for (const [module, names] of exportsByModule) { + if (!names.has(name)) continue; + byModule.set(module, [...(byModule.get(module) ?? []), name].sort()); + break; + } + } + return byModule; +} + +/** Every identifier a copied block mentions in CODE (comments and string literals stripped). */ +function referencedIdentifiers(blocks: SchemaBlock[]): string[] { + const found = new Set(); + for (const block of blocks) { + const code = block.source + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/\/\/[^\n]*/g, " ") + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/'(?:[^'\\]|\\.)*'/g, "''"); + for (const match of code.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\b/g)) found.add(match[1]!); + } + return [...found].sort(); +} + export function referencedLimits(blocks: SchemaBlock[]): string[] { const defined = new Set(blocks.map((block) => block.name)); const referenced = new Set(); @@ -237,9 +305,16 @@ import { z } from "zod"; export function renderApiSchemas(sourceText: string, documentText: string, binSource: string): string { const document = JSON.parse(documentText) as OpenApiDocument; const byPath = responseSchemaByPath(document, cliApiPaths(binSource)); - const byPattern = responseSchemaByCall(document, cliParameterisedApiCalls(binSource, document)); - const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values()])]); - const limits = referencedLimits(blocks); + const parameterisedCalls = cliParameterisedApiCalls(binSource, document); + const byPattern = responseSchemaByCall(document, parameterisedCalls); + const staticCalls = cliApiCalls(binSource); + const byRequest = new Map([...requestSchemaByCall(document, staticCalls), ...requestSchemaByCall(document, parameterisedCalls)]); + const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values(), ...byRequest.values()])]); + const exportsByModule = new Map>([ + ["./limits.js", new Set(exportedNames(readModule("packages/loopover-contract/src/limits.ts")))], + ["./api-requests.js", new Set(exportedNames(readModule("packages/loopover-contract/src/api-requests.ts")))], + ]); + const externals = referencedExternals(blocks, exportsByModule); const body = blocks .map((block) => block.source @@ -256,8 +331,15 @@ export function renderApiSchemas(sourceText: string, documentText: string, binSo .sort(([left], [right]) => left.localeCompare(right)) .map(([path, schema]) => ` "${path}": ${schema},`) .join("\n"); - const limitsImport = limits.length > 0 ? `import { ${limits.join(", ")} } from "./limits.js";\n\n` : ""; - return `${HEADER}${limitsImport}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${PATTERN_TABLE_HEADER}${patternTable}\n} as const;\n\n${TABLE_TYPES}`; + const limitsImport = [...externals.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([module, names]) => `import { ${names.join(", ")} } from "${module}";\n`) + .join(""); + const requestTable = [...byRequest.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([call, schema]) => ` "${call}": ${schema},`) + .join("\n"); + return `${HEADER}${limitsImport ? `${limitsImport}\n` : ""}${body.trimEnd()}\n\n${TABLE_HEADER}${table}\n} as const;\n\n${PATTERN_TABLE_HEADER}${patternTable}\n} as const;\n\n${REQUEST_TABLE_HEADER}${requestTable}\n} as const;\n\n${TABLE_TYPES}`; } const TABLE_HEADER = `/** @@ -279,6 +361,16 @@ const PATTERN_TABLE_HEADER = `/** export const CLI_PARAMETERISED_RESPONSE_SCHEMAS = { `; +const REQUEST_TABLE_HEADER = `/** + * What the CLI must SEND, per call (#9773) -- keyed \`METHOD path\`, patterns and literals alike. + * + * A call absent here has no named request schema in the document and keeps an unchecked body, exactly as + * before. Adding one is a matter of declaring \`request.body\` on that route's spec; the schemas already + * live in @loopover/contract/api-requests. + */ +export const CLI_REQUEST_SCHEMAS = { +`; + const TABLE_TYPES = `/** A path the client validates. */ export type ValidatedApiPath = keyof typeof CLI_RESPONSE_SCHEMAS; @@ -310,14 +402,40 @@ export type MatchApiCall = { [Call in ParameterisedApiCall]: Call extends \`\${Method} \${infer Pattern}\` ? (Path extends TemplatedApiPath ? Call : never) : never; }[ParameterisedApiCall]; +/** A call whose request body the client type-checks. */ +export type RequestCheckedApiCall = keyof typeof CLI_REQUEST_SCHEMAS; + +/** The call a concrete METHOD + path matches in the request table, or \`never\`. */ +export type MatchRequestCall = { + [Call in RequestCheckedApiCall]: Call extends \`\${Method} \${infer Pattern}\` ? (Path extends TemplatedApiPath ? Call : never) : never; +}[RequestCheckedApiCall]; + +/** + * The body a concrete call must send, or \`unknown\` when the document does not describe one. + * + * \`unknown\` rather than \`never\` for the undescribed case: an unchecked body must still be ACCEPTED -- + * this narrows what it can be where the contract knows, and gets out of the way where it does not. + */ +export type ApiRequestBody = MatchRequestCall extends never + ? unknown + : z.input<(typeof CLI_REQUEST_SCHEMAS)[MatchRequestCall]>; + /** The parsed response for a concrete parameterised call. */ export type ParameterisedApiResponse = z.infer< (typeof CLI_PARAMETERISED_RESPONSE_SCHEMAS)[MatchApiCall] >; `; +/** The names a module exports, for resolving what a copied schema references. */ +export function exportedNames(source: string): string[] { + return [...source.matchAll(/^export (?:const|function|type) ([A-Za-z_][A-Za-z0-9_]*)/gm)].map((match) => match[1]!); +} + +let readModule: (path: string) => string = (path) => readFileSync(path, "utf8"); + export function generate(deps: { readFile?: (path: string) => string } = {}): string { const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8")); + readModule = readFile; return renderApiSchemas(readFile(SOURCE), readFile(OPENAPI_DOCUMENT), readFile(CLI_BIN)); } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5857d51177..77f602d9e0 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1,6 +1,8 @@ import { z } from "zod"; // #9762: the enums these schemas validate against, from the one place that defines them. import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, FEASIBILITY_VERDICTS, PUBLIC_SURFACE_SKIP_REASONS } from "@loopover/contract"; +// #9773: the request bodies these routes really accept, from the one place they are defined. +import { checkBeforeStartSchema, slopRiskSchema, validateFocusManifestSchema, validateLinkedIssueSchema } from "@loopover/contract/api-requests"; import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions"; import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types"; import { @@ -2138,20 +2140,15 @@ export const EvaluateEscalationResponseSchema = z * `ValidateLinkedIssueInput` in @loopover/contract (the `loopover_validate_linked_issue` MCP tool `inputSchema`) in * src/mcp/server.ts — #9304. owner/repo are also path params; the body carries the planned-change context. */ -export const ValidateLinkedIssueRequestSchema = z - .object({ - owner: z.string(), - repo: z.string(), - issueNumber: z.number().int().positive(), - plannedChange: z - .object({ - title: z.string().optional(), - changedFiles: z.array(z.string()).optional(), - contributorLogin: z.string().optional(), - }) - .optional(), - }) - .openapi("ValidateLinkedIssueRequest"); +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const ValidateLinkedIssueRequestSchema = z.object(validateLinkedIssueSchema.shape).openapi("ValidateLinkedIssueRequest"); /** * Response body for POST /v1/repos/{owner}/{repo}/validate-linked-issue. Field-level parity with @@ -2175,15 +2172,15 @@ export const ValidateLinkedIssueResponseSchema = z * Request body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with * `CheckBeforeStartInput` in @loopover/contract (the `loopover_check_before_start` MCP tool `inputSchema`) — #9304. */ -export const CheckBeforeStartRequestSchema = z - .object({ - owner: z.string(), - repo: z.string(), - issueNumber: z.number().int().positive().optional(), - title: z.string().optional(), - plannedPaths: z.array(z.string()).optional(), - }) - .openapi("CheckBeforeStartRequest"); +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const CheckBeforeStartRequestSchema = z.object(checkBeforeStartSchema.shape).openapi("CheckBeforeStartRequest"); /** * Response body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with @@ -3531,17 +3528,15 @@ export const LintPrTextResponseSchema = z .openapi("LintPrTextResponse"); /** Request body for POST /v1/lint/slop-risk — parity with `checkSlopRiskShape` (loopover_check_slop_risk). */ -export const CheckSlopRiskRequestSchema = z - .object({ - changedFiles: z.array(ChangedFileSchema), - description: z.string().optional(), - tests: z.array(z.string()).optional(), - testFiles: z.array(z.string()).optional(), - commitMessages: z.array(z.string()).optional(), - hasLinkedIssue: z.boolean().optional(), - issueDiscoveryLane: z.boolean().optional(), - }) - .openapi("CheckSlopRiskRequest"); +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const CheckSlopRiskRequestSchema = z.object(slopRiskSchema.shape).openapi("CheckSlopRiskRequest"); /** Response body for POST /v1/lint/slop-risk — parity with `checkSlopRiskOutputSchema`. */ export const CheckSlopRiskResponseSchema = z @@ -3658,12 +3653,15 @@ export const CheckIssueSlopResponseSchema = z .openapi("CheckIssueSlopResponse"); /** Request body for POST /v1/validate/focus-manifest — parity with `ValidateConfigInput` in @loopover/contract (loopover_validate_config). */ -export const ValidateFocusManifestRequestSchema = z - .object({ - content: z.string(), - source: z.enum(["repo_file", "api_record", "none"]).optional(), - }) - .openapi("ValidateFocusManifestRequest"); +/** Request body for the route -- the schema its HANDLER parses with, not a parallel declaration. + * #9773: the hand-written copy had drifted from it -- requiring path params in the body, requiring a field + * the handler has optional, or typing an enum as a free string. + * + * Rebuilt with `z.object(shape)` rather than used directly: `.openapi()` exists only on schemas + * constructed after `extendZodWithOpenApi` ran, and @loopover/contract must never run it (it is the + * zod-only leaf every runtime depends on). Re-wrapping the SHAPE keeps one definition of the fields while + * giving the document a decorated object it can name. */ +export const ValidateFocusManifestRequestSchema = z.object(validateFocusManifestSchema.shape).openapi("ValidateFocusManifestRequest"); /** Response body for POST /v1/validate/focus-manifest — parity with `ValidateConfigOutput` in @loopover/contract. */ export const ValidateFocusManifestResponseSchema = z From 239b70502995dcf27f0ed29da1c81af8aabf52f6 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:03:48 -0700 Subject: [PATCH 4/4] types(mcp): narrow the CLI's closed-set guards, and discover the contract's modules (#9773) Two things the merge with #9762 exposed, now that the action-class and autonomy-level lists are readonly literal tuples rather than `string[]`: - The CLI validated `` and `` with `LIST.includes(value)` and then passed the still- `string` value to a typed request. `includes` returns a boolean and narrows nothing, so the check ran and the type system learned nothing from it. `isOneOf` is the same check written as a type predicate, so a validated value arrives at the API as the union it was just proved to be. - The generator resolved a copied schema's constants against a hardcoded pair of contract modules. That is a hand-maintained list by another name, and it fails in the quietest way available: a constant that moves between modules yields a generated file referencing a name it never imported. It now reads the contract's source directory, so a constant can move -- or a module can appear -- without this script knowing anything about it. Regression test pins the discovery against wherever PUBLIC_SURFACE_SKIP_REASONS lives, rather than against the module it happens to live in today. --- packages/loopover-contract/src/api-schemas.ts | 5 +-- packages/loopover-mcp/bin/loopover-mcp.ts | 17 ++++++++-- scripts/gen-contract-api-schemas.ts | 33 +++++++++++++++---- test/unit/mcp-api-client.test.ts | 17 ++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/loopover-contract/src/api-schemas.ts b/packages/loopover-contract/src/api-schemas.ts index d44a1f9fbc..bce04d9344 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { checkBeforeStartSchema, slopRiskSchema, validateFocusManifestSchema, validateLinkedIssueSchema } from "./api-requests.js"; +import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS } from "./enums.js"; import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "./limits.js"; export const FindingSchema = z @@ -581,8 +582,8 @@ export const RepositorySettingsSchema = z // exact drift class #9517's enum notes warned about, republished here. The compile-time parity // assertion in src/openapi/schema-type-parity.ts is what finally caught it. autonomy: z.partialRecord( - z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"]), - z.enum(["observe", "auto_with_approval", "auto"]), + z.enum(AGENT_ACTION_CLASSES), + z.enum(AUTONOMY_LEVELS), ), autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), agentPaused: z.boolean().optional(), diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4cca0299dc..1602c30004 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -2801,7 +2801,7 @@ export async function maintainCli(args: readonly string[]) { if (!actionClass || !pullArg) { throw new Error("Usage: loopover-mcp maintain propose --repo owner/repo [--reason ...] [--label ...] [--review-body ...] [--merge-method merge|squash|rebase] [--close-comment ...]."); } - if (!PROPOSE_ACTION_CLASSES.includes(actionClass)) throw new Error(`Unknown action class: ${actionClass}. Use ${PROPOSE_ACTION_CLASSES.join(", ")}.`); + if (!isOneOf(PROPOSE_ACTION_CLASSES, actionClass)) throw new Error(`Unknown action class: ${actionClass}. Use ${PROPOSE_ACTION_CLASSES.join(", ")}.`); const pullNumber = Number(pullArg); if (!Number.isInteger(pullNumber) || pullNumber <= 0) throw new Error(`Invalid pull number: ${pullArg}. Pass a positive integer.`); const payload = await apiPost( @@ -2824,8 +2824,8 @@ export async function maintainCli(args: readonly string[]) { const action = args[1] && !args[1].startsWith("--") ? args[1] : undefined; const level = args[2] && !args[2].startsWith("--") ? args[2] : undefined; if (!action || !level) throw new Error("Usage: loopover-mcp maintain set-level --repo owner/repo."); - if (!MAINTAIN_ACTION_CLASSES.includes(action)) throw new Error(`Unknown action: ${action}. Use ${MAINTAIN_ACTION_CLASSES.join(", ")}.`); - if (!MAINTAIN_AUTONOMY_LEVELS.includes(level)) throw new Error(`Unknown level: ${level}. Use ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}.`); + if (!isOneOf(MAINTAIN_ACTION_CLASSES, action)) throw new Error(`Unknown action: ${action}. Use ${MAINTAIN_ACTION_CLASSES.join(", ")}.`); + if (!isOneOf(MAINTAIN_AUTONOMY_LEVELS, level)) throw new Error(`Unknown level: ${level}. Use ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}.`); // Read-merge-write so one class is updated without clearing the others. const current = await apiGet(`${repoBase}/settings`); const autonomy = { ...(current.autonomy ?? {}), [action]: level }; @@ -4368,6 +4368,17 @@ function printProfileHelp() { process.stdout.write(printableUsage("profile")); } +/** + * Whether a user-supplied string is one of a closed set (#9773). + * + * A TYPE PREDICATE, so the value narrows for whatever it is passed to next. `list.includes(value)` returns + * a boolean and narrows nothing, which is why an action class or autonomy level still arrived at the API + * as a plain `string` -- the check ran, and the type system learned nothing from it. + */ +function isOneOf(list: T, value: string): value is T[number] { + return (list as readonly string[]).includes(value); +} + /** * An option's value as text (#9773). * diff --git a/scripts/gen-contract-api-schemas.ts b/scripts/gen-contract-api-schemas.ts index bae2086a73..22562c5d39 100644 --- a/scripts/gen-contract-api-schemas.ts +++ b/scripts/gen-contract-api-schemas.ts @@ -17,7 +17,7 @@ // by construction -- and the contract copy is generated with the names stripped, which the CLI does not // need: it only parses and infers. `--check` in test:ci is what makes the copy safe, exactly like // gen-selfhost-env-reference.ts and gen-command-reference.ts. -import { readFileSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; const SOURCE = "src/openapi/schemas.ts"; @@ -310,11 +310,7 @@ export function renderApiSchemas(sourceText: string, documentText: string, binSo const staticCalls = cliApiCalls(binSource); const byRequest = new Map([...requestSchemaByCall(document, staticCalls), ...requestSchemaByCall(document, parameterisedCalls)]); const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values(), ...byRequest.values()])]); - const exportsByModule = new Map>([ - ["./limits.js", new Set(exportedNames(readModule("packages/loopover-contract/src/limits.ts")))], - ["./api-requests.js", new Set(exportedNames(readModule("packages/loopover-contract/src/api-requests.ts")))], - ]); - const externals = referencedExternals(blocks, exportsByModule); + const externals = referencedExternals(blocks, contractModuleExports()); const body = blocks .map((block) => block.source @@ -426,16 +422,39 @@ export type ParameterisedApiResponse >; `; +const CONTRACT_SRC = "packages/loopover-contract/src"; + +/** + * What every sibling contract module exports, DISCOVERED rather than listed (#9773). + * + * A hardcoded module list is a hand-maintained list by another name, and it fails in the quietest way + * there is: `PUBLIC_SURFACE_SKIP_REASONS` moved between two contract modules, and a generator that only + * knew about the module it left would have emitted a file referencing a name it never imported -- valid + * output, broken build. Reading the directory means a constant can move, or a new module can appear, + * without this script knowing anything about it. + * + * `index.ts` is excluded because it re-exports the generated file (importing it back would be a cycle), + * and the generated file itself because a schema cannot import its own copy. + */ +export function contractModuleExports(): Map> { + const modules = listModules(CONTRACT_SRC) + .filter((file) => file.endsWith(".ts") && file !== "index.ts" && file !== "api-schemas.ts") + .sort(); + return new Map(modules.map((file) => [`./${file.replace(/\.ts$/, ".js")}`, new Set(exportedNames(readModule(`${CONTRACT_SRC}/${file}`)))])); +} + /** The names a module exports, for resolving what a copied schema references. */ export function exportedNames(source: string): string[] { return [...source.matchAll(/^export (?:const|function|type) ([A-Za-z_][A-Za-z0-9_]*)/gm)].map((match) => match[1]!); } let readModule: (path: string) => string = (path) => readFileSync(path, "utf8"); +let listModules: (dir: string) => string[] = (dir) => readdirSync(dir); -export function generate(deps: { readFile?: (path: string) => string } = {}): string { +export function generate(deps: { readFile?: (path: string) => string; listDir?: (dir: string) => string[] } = {}): string { const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8")); readModule = readFile; + listModules = deps.listDir ?? ((dir: string) => readdirSync(dir)); return renderApiSchemas(readFile(SOURCE), readFile(OPENAPI_DOCUMENT), readFile(CLI_BIN)); } diff --git a/test/unit/mcp-api-client.test.ts b/test/unit/mcp-api-client.test.ts index 280a824c30..505c8c4346 100644 --- a/test/unit/mcp-api-client.test.ts +++ b/test/unit/mcp-api-client.test.ts @@ -8,6 +8,8 @@ import { closure, declaredPathShapes, parseSchemaBlocks, + contractModuleExports, + referencedExternals, referencedLimits, responseSchemaByCall, responseSchemaByPath, @@ -149,6 +151,21 @@ describe("parameterised response schemas (#9773)", () => { expect(referencedLimits(blocks), "one declared elsewhere is imported").toContain("MAX_REVIEW_NAG_COOLDOWN_DAYS"); }); + it("discovers which contract module a referenced constant lives in, so it may move", () => { + // A hardcoded module list fails silently when a constant relocates: PUBLIC_SURFACE_SKIP_REASONS moved + // between two contract modules, and a generator that still only knew the old one would have emitted a + // file referencing a name it never imported -- valid TypeScript, broken build. + const modules = contractModuleExports(); + expect([...modules.keys()], "index.js would import this file back").not.toContain("./index.js"); + expect([...modules.keys()], "the generated file cannot import its own copy").not.toContain("./api-schemas.js"); + + const owner = [...modules].find(([, names]) => names.has("PUBLIC_SURFACE_SKIP_REASONS")); + expect(owner?.[0], "wherever it lives today, it is found there").toMatch(/^\.\/[a-z-]+\.js$/); + expect(referencedExternals([{ name: "XSchema", exported: true, source: "const XSchema = z.enum(PUBLIC_SURFACE_SKIP_REASONS);" }], modules).get(owner![0])).toEqual([ + "PUBLIC_SURFACE_SKIP_REASONS", + ]); + }); + it("does not mistake a capitalised word in prose for a constant", () => { // The first cut scanned comments too and emitted an import for DELETE, REQUIRED, REST and friends. expect(referencedLimits([{ name: "XSchema", exported: true, source: '// DELETE and REQUIRED and REST\nconst XSchema = z.string();' }])).toEqual([]);