Repository navigation
Find operations by what they are not, so no HTTP verb can drop one in silence - #931
Conversation
Every per-language generator found operations by iterating its own hard-coded list of five HTTP verbs. Smithy's @http trait takes the method as a free-form string it will use literally and perform no validation on, so method: "HEAD" in the model produced a valid openapi.json and no method on any client, in six languages, with no error. Each generator now reads a Path Item Object by exclusion — its non-operation fields are a closed, spec-defined set and its extensions are x- prefixed, so every other field is an operation — and refuses by name what it cannot emit. Discovery is total; emission is bounded by spec/generated-verbs.json, one declaration all six read rather than six literals that could only ever agree. The metadata extractors and the route generator take no bound at all: they key everything on operationId, so they are verb-agnostic and now cover an operation on any verb. Closes #925.
Both stand in for the repository layout the generators resolve against: the TypeScript drift check assembles a throwaway project outside the repo, and the Rust fixture roots are self-contained. Copy the file into the one, pass --verbs to the other — a per-fixture copy would put the duplicated literal back into the tests.
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
🟡 Changes recommended
The shared policy permits broken PATCH emission, and several fail-closed paths remain incomplete.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Closes #925 by replacing fixed HTTP-verb discovery with fail-closed OpenAPI path-item traversal across SDK generators.
Changes:
- Centralizes supported verbs in
spec/generated-verbs.json. - Updates generators, metadata extractors, route generation, and parity checks.
- Adds mutation coverage and CI wiring.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
File summaries
| File | Description |
|---|---|
| typescript/scripts/path-items.ts | Adds shared TypeScript path-item traversal. |
| typescript/scripts/generate-services.ts | Uses bounded fail-closed operation discovery. |
| typescript/scripts/extract-person-id-sites.ts | Makes person-ID extraction verb-agnostic. |
| typescript/scripts/extract-metadata.ts | Makes metadata extraction verb-agnostic. |
| swift/Sources/BasecampGenerator/PathItems.swift | Adds Swift path-item traversal and verb loading. |
| swift/Sources/BasecampGenerator/OpenAPIParser.swift | Uses fail-closed Swift discovery. |
| swift/Sources/BasecampGenerator/main.swift | Adds the Swift verb-policy argument. |
| spec/tracking-issues.yml | Removes resolved #925 tracking entries. |
| spec/generated-verbs.json | Defines the shared ordered verb policy. |
| scripts/test-generator-verb-inversion.rb | Adds cross-generator mutation tests. |
| scripts/test-check-operation-assignment-parity.rb | Updates parity-test operation discovery. |
| scripts/generate-url-routes | Makes route discovery verb-agnostic. |
| scripts/gen-catalog/main.go | Updates documentation for the remaining catalog bound. |
| scripts/check-typescript-service-drift.sh | Copies the shared policy into the test layout. |
| scripts/check-required-tags.rb | Documents the resolved generator gap. |
| scripts/check-operation-assignment-parity | Makes the parity anchor fail closed. |
| rust/generator/tests/fixtures.rs | Supplies the shared verb policy to fixtures. |
| rust/generator/src/model.rs | Adds bounded Rust operation discovery. |
| rust/generator/src/main.rs | Loads the Rust verb-policy input. |
| ruby/scripts/generate-services.rb | Adds fail-closed Ruby service discovery. |
| ruby/scripts/generate-metadata.rb | Makes Ruby metadata traversal verb-agnostic. |
| python/scripts/generate_services.py | Uses shared Python traversal and policy. |
| python/scripts/generate_metadata.py | Makes Python metadata traversal verb-agnostic. |
| python/scripts/gen_common.py | Adds shared Python path-item utilities. |
| Makefile | Registers the mutation test in checks. |
| kotlin/generator/src/test/kotlin/com/basecamp/sdk/generator/PaginationStyleTest.kt | Updates parser construction for the new policy. |
| kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/PathItems.kt | Adds Kotlin traversal and verb loading. |
| kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/OperationParser.kt | Uses bounded Kotlin operation discovery. |
| kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Main.kt | Supplies the Kotlin verb policy. |
| .github/workflows/test.yml | Runs the mutation test in Spec Gates. |
Review details
- Files reviewed: 30/30 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| val parser = OperationParser(api, PathItems.generatedVerbs(verbsPath)) | ||
| val services = parser.groupOperations() |
| "The array is ORDERED, and the order is emission order. Keeping order here rather than in a second per-generator list means there is exactly one place the verb set is written." | ||
| ], | ||
| "adding_a_verb": "Adding an entry here does not implement it. Each SDK's runtime needs the transport helper the generated call site names (Basecamp::Http#http_head, BaseService.httpHead, and so on) before the verb belongs in this list, and the operation needs a tag in spec/overlays/tags.smithy. Add the six implementations first, this entry last.", | ||
| "verbs": ["get", "post", "put", "patch", "delete"] |
| # openapi.json's four verbs, so this drives them with crafted specs — HEAD, 3.2's | ||
| # `query`, an unreadable field, a $ref path item — across Ruby, Python and the jq | ||
| # route generator. Stdlib toolchains only, so it belongs in the spec-gates CI job. |
| val verbs = (Json.parseToJsonElement(file.readText()) as JsonObject)["verbs"] | ||
| ?.jsonArray | ||
| ?.map { it.jsonPrimitive.content } | ||
| ?: error("$path must declare a `verbs` array") | ||
| require(verbs.isNotEmpty() && verbs.none { it.isEmpty() }) { | ||
| "$path must declare a non-empty `verbs` array of strings" | ||
| } |
| let emittable_verbs: Vec<String> = verbs["verbs"] | ||
| .as_array() | ||
| .map(|verbs| { | ||
| verbs | ||
| .iter() | ||
| .filter_map(|verb| verb.as_str().map(str::to_string)) | ||
| .collect() | ||
| }) | ||
| .filter(|verbs: &Vec<String>| !verbs.is_empty()) |
| ($path_item | to_entries | map( | ||
| select(.key | IN("get","post","put","patch","delete")) | ||
| select((.key | IN("summary","description","servers","parameters") | not) | ||
| and (.key | startswith("x-") | not)) | ||
| )) as $ops | |
| @@ -71,8 +78,9 @@ func parseAllOperations(spec: [String: Any]) -> (operations: [ParsedOperation], | |||
| let pathItem = paths[path]! | |||
| guard let pathDict = pathItem as? [String: Any] else { continue } | |||
| return fields.map((field): [string, T] => { | ||
| const operation = item[field]; | ||
| if (typeof operation !== "object" || operation === null || Array.isArray(operation)) { | ||
| die( | ||
| `Error: openapi.json path ${path} field ${JSON.stringify(field)} is neither a known ` + | ||
| `non-operation field nor an operation object. If a later OpenAPI version added it, add ` + | ||
| `it to NON_OPERATION_FIELDS with a reason.` | ||
| ); | ||
| } | ||
| if (emittable && !emittable.includes(field)) { |
|
All eight findings from the review were real, and all eight are fixed in #932. This PR merged about nine minutes after opening — roughly forty seconds before the review landed — so none of them could be addressed here. Per finding:
The self-test is up from 21 cases to 28 and |
11 version files and 8 lockfiles, written by scripts/bump-version.sh. MIGRATING.md's '# Unreleased' section is promoted to '# v0.20.0'. Thirteen commits since v0.19.0 (2026-09-16), the substantial ones being the #925 chain — #931 finding operations by exclusion so no HTTP verb can drop one in silence, #932 stopping a refusal from destroying generated output, and #938 deriving gen-catalog's emission bound from the shared declaration — plus #926's embedded tool Catalog, #930's Automation split, #924's event-feed body validation at the wrapper's decode, and #937's anyio bump for CVE-2026-63374. Ran the release's own preflight here rather than discovering it at tag time: promote-migrating --check passes, rs-publish-check packages and dry-run-uploads 0.20.0 clean, and all four lockfile freshness assertions hold.
The hole
Every per-language generator found operations by iterating its own hard-coded list of five HTTP verbs —
get,post,put,patch,delete. Smithy's@httptrait takesmethodas a free-form string that Smithy "will use literally and will perform no validation on", so a model author writingmethod: "HEAD"inspec/basecamp.smithygot a valid model, a validopenapi.json, and no method on any client, in six languages, with no error.Measured rather than asserted. Taking the committed
openapi.json, renamingGetProject's verb fromgettohead, and running the pre-change generators:GetProjectpresentOne operation gone from the SDK, and a clean exit.
The instrument, per #922
Not a longer list. #922 fixed the same shape in
scripts/check-required-tags.rbby identifying an operation by what it is not, and the same inversion lands here: a Path Item Object's non-operation fields are a closed, spec-defined set ($ref,summary,description,servers,parameters) plusx-extensions, and every other field is an operation. A field that is neither a known non-operation field nor a readable operation object is named, not stepped over. A$refpath item is refused rather than read as empty, since its operations cannot be counted without resolving it.The verb list has already fallen behind twice — OpenAPI 3.0 added
trace, 3.2 addsqueryandadditionalOperations— and@http.methodmeans this repo's own generator can put a key in a path item that no list anticipates.Loud refusal, not new emission — and the reason this matters beyond this PR
Chosen: the generators refuse an operation on a verb they cannot emit. They were deliberately not taught to emit new verbs.
Emitting HEAD/OPTIONS/TRACE would mean inventing transport helpers in six runtimes for a verb the Basecamp API does not serve — API surface nobody asked for, six times. Refusal costs nothing and is fail-closed: the operation cannot ship missing, because nothing ships until someone decides.
The consequence, stated because a follow-up depends on it:
scripts/gen-catalog/main.gobounds the catalog to the generated verb set and fails the build on anything else. That gate stays correct and stays needed after this PR. It would have inverted into a bug only if the generators had started emitting those verbs, which they do not. Its logic is untouched here.One declaration, not six literals
The five verbs were restated independently in each generator, in
scripts/generate-url-routes, and ingen-catalog. That copying is the mechanism, not just the symptom, so it is gone:spec/generated-verbs.jsonis the single declaration, read by all six service generators. It is a policy, not six per-language capabilities:check-operation-assignment-parityalready requires all six SDKs to expose the same operation set, so six independently-maintained bounds could never legitimately differ — it was one fact written six times. The array is ordered, and that order is emission order, so there is no second per-generator list for ordering either.Resolution mirrors what each generator already does for
openapi.json: a path relative to the script (Ruby, Python, TypeScript) or a--verbsflag defaulting beside--openapi(Kotlin, Swift, Rust).gen-catalogis peer-owned; its comment now points at the declaration, and deriving from it is theirs to take.The file's own header carries the reasoning, including the part a declaration cannot enforce: adding an entry does not implement it. Six runtimes need the transport helper first.
What each generator does now
ruby/scripts/generate-services.rbpython/scripts/generate_services.pygen_common.iter_operations)typescript/scripts/generate-services.tsscripts/path-items.ts)kotlin/…/OperationParser.ktPathItems.kt)swift/…/OpenAPIParser.swiftPathItems.swift)rust/generator/src/model.rsruby/scripts/generate-metadata.rbpython/scripts/generate_metadata.pytypescript/scripts/extract-metadata.tstypescript/scripts/extract-person-id-sites.tsscripts/generate-url-routes(jq)scripts/check-operation-assignment-parityThe four metadata extractors and the route generator key everything on
operationIdand represent a verb as data, so they take no bound and now cover an operation on any verb. Bounding them would have been fail-closed theatre: they can genuinely handle it.Go is the one generator with nothing to invert. Its client comes from
oapi-codegen; there is no repo-owned path-item walk. Its route table isscripts/generate-url-routes, which is in the table above.Mutation check
scripts/test-generator-verb-inversion.rb(new, wired intomake checkand the Spec Gates job) drives Ruby, Python and the jq route generator with crafted specs — HEAD, 3.2'squery, a non-operation field, an unreadable field, a$refpath item, a declaration missingdelete. 21 cases, all passing.The numbers are the argument:
The eight-verb list repairs HEAD and still fails on 3.2's
queryand on the two cases where a field that is neither known nor readable is stepped over in silence. That gap is why this is an inversion rather than a longer list, and why it is a test rather than a paragraph.Case 9 and 17 are worth naming separately: they point a generator at a crafted declaration lacking
deleteand assertDELETEis then refused. That is what proves the bound is read fromspec/generated-verbs.jsonrather than from a private literal that merely happens to match.The compiled generators carry the identical walk; the test does not drive them (they need a JVM, a Swift toolchain, cargo or an npm install, and their drift checks run in their own CI jobs). The refusal was reproduced by hand in all four against the same HEAD spec:
No partial output was written in any of them.
Regeneration
Byte-identical in all seven SDKs.
openapi.jsondeclares four verbs and no non-operation path-item fields, so the inversion sees exactly what the lists saw. The ordering property is what makes that true and is pinned by case 8: the declaration's order is emission order, so a path item spelled{delete, get}still emitsgetfirst.Verified green locally: the six per-language drift checks,
go-check-generated-drift,go-check-wrapper-drift,check-operation-assignment-parity,check-service-inventory-parity,check-grouped-client-coverage,url-routes-check,catalog-check,smithy-check,bc3-route-parity, and every self-test alongside them.Two harness fixes were needed, both because the harness stands in for the repository layout the generators resolve against:
scripts/check-typescript-service-drift.shassembles a throwaway project outside the repo; it now copiesspec/generated-verbs.jsonto wherescripts/path-items.tsresolves it, exactly as it already did foropenapi.json.rust/generator/tests/fixtures.rspasses--verbspointing at the repository's own declaration rather than a per-fixture copy — copying it into each fixture root would reintroduce, in the tests, the duplicated literal this removes.The tracking claims, removed in this PR because it closes #925
Two canonical
tracked in #925claims and bothspec/tracking-issues.ymlrows are gone. Leaving either would have pointed theKnown-defect tracking issues are opengate at a closed issue the moment this merged — red onmainand, because it evaluates on the merge commit, on every open PR at once.scripts/check-required-tags.rb— the claim described the hole this PR closes, so the paragraph is rewritten as an as-of fact: the generators now walk by the same exclusion this gate uses. The explanation survives; only the promise is gone.scripts/gen-catalog/main.go— comment text only, no logic change. The consolidation half of that claim is obsolete (there is a shared source now, and taking it belongs to that generator's owner). The bound half is not obsolete and is not deleted: this PR made the generators refuse rather than emit, so a non-generated verb still reaches no client, andgen-catalogreadsopenapi.jsondirectly rather than the SDK trees, so it holds that line on its own input even when nothing has been regenerated.make check-known-defect-issues-openexits 0 locally, andrg 'tracked in #925'returns nothing.What this does not do
query. Those refuse, loudly.$refpath items anywhere. Every walk refuses one by name; resolving local references is the better end state and no walk under-counts while waiting for it.additionalOperationsis a map of method to Operation. Every walk here reads it as a single operation and refuses it by name. The artifact declares3.1.0, so it is unreachable; the failure is loud and the remedy is local to each walker.spec/generated-verbs.jsondeclares what the generators emit; nothing mechanically verifies each runtime actually has the helper. Adding a verb there without implementing it is a compile error in TypeScript, Kotlin, Swift and Rust, and a missing-method error at call time in Ruby and Python. The file's header says to add the implementations first; that instruction is the whole of the guard, and it is stated rather than assumed.scripts/check-projected-examples.rb,scripts/check-kotlin-optional-arrays-and-scalars.rbandscripts/sync-doc-constants.rbstill spell the eight-verb list. They are checks, not generators — none of them can drop an operation from an SDK — and they were out of Generators iterate a five-verb list, so an operation on any other HTTP method is silently dropped from every SDK #925's scope. Converting them is a small, separate change.Closes #925.
Summary by cubic
Fixes operations being silently dropped from generated SDKs when the model uses an HTTP verb outside the previous five-verb list. Generators now find every operation in a path item and refuse, by name, any operation they cannot emit.
What changed
x-extensions are skipped; every other field is treated as an operation.spec/generated-verbs.jsonis now the single ordered declaration of which verbs the SDKs emit.$refpath items stop generation rather than being silently ignored.Side effects
queryfail loudly.spec/generated-verbs.jsonrequires the corresponding transport helper in all six runtimes first.Closes #925.
Written for commit a7f1229. Summary will update on new commits.