diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 50b8e2576..142b6e58f 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 b71d72fc7..bce04d934 100644 --- a/packages/loopover-contract/src/api-schemas.ts +++ b/packages/loopover-contract/src/api-schemas.ts @@ -8,6 +8,10 @@ // 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 { 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 .object({ code: z.string(), @@ -47,6 +51,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 +102,232 @@ 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(), + 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 +351,411 @@ 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(), + 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 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(), + 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 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(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(), + 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 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(), + 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(), @@ -297,6 +946,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(), @@ -387,6 +1108,71 @@ 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(), + 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. + */ +/** 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(), + 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(), @@ -409,6 +1195,423 @@ 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 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"]); + +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 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([ @@ -680,6 +1883,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(), @@ -786,6 +2070,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(), @@ -797,6 +2096,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(), @@ -806,6 +2116,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(), @@ -814,6 +2135,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(), @@ -826,6 +2157,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(), @@ -833,6 +2173,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(), @@ -842,6 +2192,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(), @@ -883,8 +2244,115 @@ 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 = { + "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; + +/** + * 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; /** 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 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. + * + * 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 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 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 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-contract/src/cli-config.ts b/packages/loopover-contract/src/cli-config.ts index 6142c9ea9..82a767743 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-contract/src/limits.ts b/packages/loopover-contract/src/limits.ts index 2549466e7..64e87df13 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 9681827a6..1602c3000 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -6,7 +6,16 @@ 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 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, @@ -355,6 +364,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 +580,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 { @@ -889,7 +920,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`)); }, ); @@ -897,7 +928,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`)); }, ); @@ -905,7 +936,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`)); }, ); @@ -918,7 +949,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)}`), @@ -929,7 +960,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`)); }, ); @@ -943,7 +974,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}` : ""}`)); }, ); @@ -954,7 +985,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`, {})); }, ); @@ -962,7 +993,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`)); }, ); @@ -973,7 +1004,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`)); }, ); @@ -985,7 +1016,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).`, @@ -1000,7 +1031,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`)); }, ); @@ -1008,7 +1039,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`)); }, ); @@ -1016,7 +1047,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`)); }, ); @@ -1024,7 +1055,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`)); }, ); @@ -1032,7 +1063,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`)); }, ); @@ -1040,7 +1071,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`)); }, ); @@ -1077,7 +1108,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)); }, @@ -1086,7 +1117,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 } : {}), @@ -1285,7 +1316,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 +1335,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. @@ -1373,7 +1404,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}`, @@ -1389,7 +1420,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}`, @@ -1402,7 +1433,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}`, @@ -1418,7 +1449,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`)); }, ); @@ -1823,7 +1854,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: any, repo: any) { +/** + * 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)}`; } @@ -2692,15 +2731,20 @@ 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); - const repoBase = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + // #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/${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`); @@ -2757,7 +2801,7 @@ export async function maintainCli(args: any) { 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( @@ -2780,8 +2824,8 @@ export async function maintainCli(args: any) { 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 }; @@ -3018,7 +3062,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 +3088,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 +3121,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 +3153,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"); + 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"); 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 +3190,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 +3234,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; + let prBody = optionText(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,15 +3288,20 @@ 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 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"); - } + const content = readCliTextFile(optionText(options.file) ?? "", "Manifest"); + // #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 } : {}), @@ -3301,11 +3350,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; + let description = optionText(options.description) ?? optionText(options.body); + const descriptionFile = optionText(options.descriptionFile) ?? optionText(options.bodyFile); if (descriptionFile) { description = readCliTextFile(descriptionFile, "Description"); } @@ -3342,7 +3391,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 +3436,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 +3492,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 +3503,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 +3540,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 +3574,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,12 +3614,16 @@ 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; + 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) @@ -3582,14 +3636,14 @@ async function explainReviewRiskCli(options: any) { "/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) { @@ -3616,9 +3670,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,8 +3690,8 @@ 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) { - const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; +function watchIssuesRequest(login: string, action: any, repoFullName?: any, labels?: any) { + 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); @@ -3647,12 +3701,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 +3781,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 +3807,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 +3832,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 +3858,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 +3867,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 +3875,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 +3883,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 +3941,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 +3990,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 +4073,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 +4082,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 +4118,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 +4145,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 +4156,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 +4171,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 +4184,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 +4274,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 +4286,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 +4368,43 @@ function printProfileHelp() { process.stdout.write(printableUsage("profile")); } -function parseOptions(args: any) { - const options: any = {}; +/** + * 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). + * + * 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 +4429,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 +4444,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 +4454,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 +4466,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 +4513,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 +4541,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 +4578,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 +4629,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 +4697,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 +4710,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 +4803,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 +5001,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 +5011,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 +5054,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 +5078,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 +5111,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 +5164,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 +5202,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 +5215,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 +5231,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 +5251,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 +5267,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 +5374,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 +5426,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 +5439,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 +5465,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 +5484,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 +5515,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 +5544,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 +5554,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 +5567,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 +5596,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 +5614,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 +5639,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 +5740,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,13 +5866,29 @@ function validatedPathOf(path: string): ValidatedApiPath | null { * would be a false claim about how much is actually validated. */ async function apiGet(path: Path): Promise>; -async function apiGet(path: string): 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: MatchApiCall<"GET", Path> extends never ? never : Path): 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: 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) }); } @@ -6219,15 +6341,15 @@ 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, - 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 866a9c1bf..22562c5d3 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"; @@ -43,11 +43,141 @@ 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. + */ +/** + * 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. + */ +/** 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(); + for (const documentPath of documented) shapes.set(documentPath.replace(/\{[^}]+\}/g, "{}"), documentPath); + + const declared = declaredPathShapes(binSource); + const found = new Set(); + 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) { + 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(`${method} ${documentPath}`); + } + 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(); + 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; @@ -84,7 +214,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); @@ -95,6 +227,69 @@ 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. + */ +/** + * 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(); + 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 @@ -108,8 +303,14 @@ 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 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 externals = referencedExternals(blocks, contractModuleExports()); const body = blocks .map((block) => block.source @@ -122,7 +323,19 @@ 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"); + 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 = `/** @@ -135,15 +348,113 @@ 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 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; /** 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 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. + * + * 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 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 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 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] +>; `; -export function generate(deps: { readFile?: (path: string) => string } = {}): string { +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; 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/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5857d5117..77f602d9e 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 diff --git a/test/unit/mcp-api-client.test.ts b/test/unit/mcp-api-client.test.ts index 2dcd5cade..505c8c434 100644 --- a/test/unit/mcp-api-client.test.ts +++ b/test/unit/mcp-api-client.test.ts @@ -2,7 +2,18 @@ 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, + contractModuleExports, + referencedExternals, + 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 +110,64 @@ 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("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([]); + }); +}); diff --git a/test/unit/mcp-cli-bool-flag-parsing.test.ts b/test/unit/mcp-cli-bool-flag-parsing.test.ts index 087b73f8c..73d304304 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 6f4316d1c..8efb3addb 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 cb5d5ad6a..ee84d2200 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 d91f6cd0d..2e72b803b 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;