Skip to content

Commit f01c2d4

Browse files
authored
types(mcp): type the stdio CLI end to end, from options to request bodies (#9773) (#9785)
* types(mcp): type the stdio CLI's option plumbing, fixing three crashes it hid (#9773) `parseOptions` is now typed from CLI_FLAG_SPEC -- repeatable flags as arrays, boolean flags as booleans, anything else as `string | boolean` behind an index signature, because the parser genuinely accepts any `--flag` and a closed record would be a lie. That type flows into every `options` parameter, every argv parameter becomes `readonly string[]`, and the config parameters take the contract's LoopoverConfig. Three defects fell out immediately, each reproduced against main before the fix: TypeError: (options[key] ?? []) is not iterable repoFullName.includes is not a function LoopOver API 404: {"error":"not_found"} The first is `--issue --issue 5`: a bare repeatable flag is stored as `true` by the no-value branch, and the accumulator then spread it. Anything not already a list now starts a fresh one -- the only sane reading of a flag that carried no value to keep. The second is `maintain <sub> --repo` with no value. `true` passed the `!repoFullName` truthiness guard and then died on a string method, where "Pass --repo owner/repo." was intended. The third is a bare `--login`, read as the literal string "true", so `decision-pack --login` requested a contributor NAMED "true" and reported them not found instead of saying the value was missing. Options are read through optionText() now, which treats a valueless flag as absent -- and every one of those call sites already had an env or profile fallback for absent. Also: the contract's LoopoverConfig was missing `session`, `telemetryEnabled`, and profile `createdAt`, all three read and written by the CLI with nothing checking they existed. The legacy top-level `session` is still written on the default profile so an older CLI reading the same file keeps working, which is exactly why it cannot be left undeclared. 277 -> 184 `: any` occurrences in the bin. The remainder is a long tail of callbacks over API payloads that stay untyped for a structural reason worth its own issue: CLI_RESPONSE_SCHEMAS covers only the 24 STATIC paths, so all 53 parameterised calls fall through to the untyped overload. #9773 stays open for that. * types(mcp): type the CLI's parameterised API calls from the published document (#9773) The second tranche. #9521 built the typed accessors; its scanner rejected any template containing an interpolation, so every per-repo and per-contributor call -- the majority of the CLI -- missed the typed overload and read its payload as `any`. The document already described most of them. Three things had to change for a composed call to resolve. The path builders now DECLARE their shape. `toolRepoBase` returned `string`, which erases the path at the type level, so `apiGet(`${toolRepoBase(o, r)}/settings`)` could never match anything; it and the 24 locally-built bases now carry template-literal types. That is also what lets the generator's scanner resolve them: it reads the same declarations the type checker does, so the two cannot disagree about what a base is. The tables are keyed by METHOD, not by path. `/v1/repos/{owner}/{repo}/agent/pending-actions` lists on GET and proposes on POST, and those return different shapes -- a path-keyed table had to guess, and the first version of this guessed `post`, handing the GET call site the POST response type. The CLI's own `payload.pendingActions` read is what contradicted it, the moment a schema was attached at all. Caught by the type checker before it shipped. And the generated copy now carries what a copied schema REFERENCES. `closure` followed only `*Schema` names, so a schema depending on a plain value beside it (`AGENT_ACTION_CLASS_VALUES`) emitted a file that would not compile. Values declared in the source are copied; anything else is imported from the contract's limits.ts, where it is restated and pinned -- and a bound missing there fails the contract build rather than emitting something broken. 30 parameterised calls are typed now, up from 8, and the guards are in mcp-api-client.test.ts: method disambiguation, base-path resolution, the copied-value closure, and the prose false-positive the first cut of the constant scanner hit (it emitted imports for DELETE, REQUIRED and REST, read out of doc comments). Still `any` at the fallback overload, for the endpoints whose 200 the document does not describe with a named schema. Flipping that to `unknown` leaves 72 narrowing sites, and the honest fix for them is to describe those endpoints -- #9773 stays open for it. * types(mcp): type what the CLI SENDS, and correct the four request schemas that lied (#9773) A review found a contributor login being sent to the API as boolean `true` -- a bare `--login` that my sweep had missed. The first answer was a test that grepped the source for the shape; that is a guard against one spelling, not against the defect, so it is gone. The defect is now a compile error. `apiPost`'s body is typed from the request schemas the published document names, so an option value -- `string | boolean | string[]`, because a bare flag is `true` -- cannot reach a field the API declares as a string. Verified by reverting one fix and watching tsc say `Type 'boolean' is not assignable to type 'string'`. Making the types BINDING mattered as much as adding them. The fallback overloads accepted `path: string, body: unknown`, so a call that failed a typed overload did not error -- it fell through and was accepted unchecked. They now refuse any path the typed overloads cover. That found three more instances of the reviewer's class, each in a different command: `lint-pr-text` sent `--body` as `true`, `check-slop-risk` sent `--description` as `true`, and `validate-focus-manifest` sent `--source` as an unchecked free string where the API takes three literals -- its `.includes()` guard never narrowed, so the body kept the raw value. The last is now parsed against the contract's own enum, so the accepted values and the error naming them come from the schema the route validates with. And four published request schemas were wrong. ValidateLinkedIssueRequest required `owner` and `repo` in the BODY though both are path params; CheckSlopRiskRequest required `changedFiles` the handler has optional; ValidateFocusManifestRequest typed an enum as a free string. They were hand-written parallels of the schemas the handlers actually parse with, and they had drifted -- so they are now built from those schemas. Rebuilt via `z.object(shape)` rather than used directly, because `.openapi()` exists only after `extendZodWithOpenApi` and the contract must never run it. The generator carries what a copied schema references, resolved against what each module really exports: bounds from limits.ts, request schemas from api-requests.ts. A name in neither fails the contract build instead of emitting a dangling reference. * types(mcp): narrow the CLI's closed-set guards, and discover the contract's modules (#9773) Two things the merge with #9762 exposed, now that the action-class and autonomy-level lists are readonly literal tuples rather than `string[]`: - The CLI validated `<action>` and `<level>` with `LIST.includes(value)` and then passed the still- `string` value to a typed request. `includes` returns a boolean and narrows nothing, so the check ran and the type system learned nothing from it. `isOneOf` is the same check written as a type predicate, so a validated value arrives at the API as the union it was just proved to be. - The generator resolved a copied schema's constants against a hardcoded pair of contract modules. That is a hand-maintained list by another name, and it fails in the quietest way available: a constant that moves between modules yields a generated file referencing a name it never imported. It now reads the contract's source directory, so a constant can move -- or a module can appear -- without this script knowing anything about it. Regression test pins the discovery against wherever PUBLIC_SURFACE_SKIP_REASONS lives, rather than against the module it happens to live in today.
1 parent 55b6a82 commit f01c2d4

12 files changed

Lines changed: 2305 additions & 278 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15869,51 +15869,60 @@
1586915869
"type": "object",
1587015870
"properties": {
1587115871
"path": {
15872-
"type": "string"
15872+
"type": "string",
15873+
"minLength": 1,
15874+
"maxLength": 400
1587315875
},
1587415876
"additions": {
15875-
"type": "integer"
15877+
"type": "integer",
15878+
"minimum": 0
1587615879
},
1587715880
"deletions": {
15878-
"type": "integer"
15881+
"type": "integer",
15882+
"minimum": 0
1587915883
}
1588015884
},
1588115885
"required": [
1588215886
"path"
1588315887
]
15884-
}
15888+
},
15889+
"maxItems": 2000
1588515890
},
1588615891
"description": {
15887-
"type": "string"
15892+
"type": "string",
15893+
"maxLength": 20000
1588815894
},
1588915895
"tests": {
1589015896
"type": "array",
1589115897
"items": {
15892-
"type": "string"
15893-
}
15898+
"type": "string",
15899+
"maxLength": 400
15900+
},
15901+
"maxItems": 2000
1589415902
},
1589515903
"testFiles": {
1589615904
"type": "array",
1589715905
"items": {
15898-
"type": "string"
15899-
}
15906+
"type": "string",
15907+
"maxLength": 400
15908+
},
15909+
"maxItems": 2000
1590015910
},
1590115911
"commitMessages": {
1590215912
"type": "array",
1590315913
"items": {
15904-
"type": "string"
15905-
}
15914+
"type": "string",
15915+
"maxLength": 2000
15916+
},
15917+
"maxItems": 200
1590615918
},
1590715919
"hasLinkedIssue": {
1590815920
"type": "boolean"
1590915921
},
1591015922
"issueDiscoveryLane": {
1591115923
"type": "boolean"
1591215924
}
15913-
},
15914-
"required": [
15915-
"changedFiles"
15916-
]
15925+
}
1591715926
},
1591815927
"CheckSlopRiskResponse": {
1591915928
"type": "object",
@@ -16217,7 +16226,8 @@
1621716226
"type": "object",
1621816227
"properties": {
1621916228
"content": {
16220-
"type": "string"
16229+
"type": "string",
16230+
"maxLength": 262144
1622116231
},
1622216232
"source": {
1622316233
"type": "string",
@@ -16288,12 +16298,6 @@
1628816298
"ValidateLinkedIssueRequest": {
1628916299
"type": "object",
1629016300
"properties": {
16291-
"owner": {
16292-
"type": "string"
16293-
},
16294-
"repo": {
16295-
"type": "string"
16296-
},
1629716301
"issueNumber": {
1629816302
"type": "integer",
1629916303
"minimum": 0,
@@ -16303,23 +16307,27 @@
1630316307
"type": "object",
1630416308
"properties": {
1630516309
"title": {
16306-
"type": "string"
16310+
"type": "string",
16311+
"minLength": 1,
16312+
"maxLength": 300
1630716313
},
1630816314
"changedFiles": {
1630916315
"type": "array",
1631016316
"items": {
16311-
"type": "string"
16312-
}
16317+
"type": "string",
16318+
"maxLength": 300
16319+
},
16320+
"maxItems": 200
1631316321
},
1631416322
"contributorLogin": {
16315-
"type": "string"
16323+
"type": "string",
16324+
"minLength": 1,
16325+
"maxLength": 100
1631616326
}
1631716327
}
1631816328
}
1631916329
},
1632016330
"required": [
16321-
"owner",
16322-
"repo",
1632316331
"issueNumber"
1632416332
]
1632516333
},
@@ -16358,31 +16366,25 @@
1635816366
"CheckBeforeStartRequest": {
1635916367
"type": "object",
1636016368
"properties": {
16361-
"owner": {
16362-
"type": "string"
16363-
},
16364-
"repo": {
16365-
"type": "string"
16366-
},
1636716369
"issueNumber": {
1636816370
"type": "integer",
1636916371
"minimum": 0,
1637016372
"exclusiveMinimum": true
1637116373
},
1637216374
"title": {
16373-
"type": "string"
16375+
"type": "string",
16376+
"minLength": 1,
16377+
"maxLength": 300
1637416378
},
1637516379
"plannedPaths": {
1637616380
"type": "array",
1637716381
"items": {
16378-
"type": "string"
16379-
}
16382+
"type": "string",
16383+
"maxLength": 300
16384+
},
16385+
"maxItems": 200
1638016386
}
16381-
},
16382-
"required": [
16383-
"owner",
16384-
"repo"
16385-
]
16387+
}
1638616388
},
1638716389
"CheckBeforeStartResponse": {
1638816390
"type": "object",

0 commit comments

Comments
 (0)