Repository navigation
Conversation
The six generators each read spec/generated-verbs.json and each validated it, and they disagreed about what it meant five times in four review rounds — Kotlin's JsonPrimitive rendering a number as text, Rust's filter_map dropping a non-string, Ruby's ASCII String#strip keeping U+00A0 and U+2003, Swift's allSatisfy being vacuously true for the empty string, JavaScript's $ matching before a final line terminator. Every one of those on invalid input, and each in a different language for a different reason. Two repairs were tried. Requiring a non-blank string moved the disagreement from "is this a string" to "what counts as blank". A positive [a-z]+ format rule moved it again, to "what does this language's predicate actually match". The population was never bounded by the rule being expressed; it is bounded by the union of six standard libraries' string and regex semantics. So the loaders stopped validating. scripts/check-generated-verbs.rb is the only thing that rejects a malformed declaration, and it is strict enough that valid input has no interpretive room left: a non-empty array of distinct lowercase ASCII tokens, no unexpected top-level keys. Every JSON parser in the six languages agrees about a file like that. It is an order-only prerequisite of every *-generate target rather than a CI job. There is no single generation entry point — ts-generate, rb-generate, py-generate, swift-generate, kt-generate-services and rs-generate-services are each directly invokable and `generate` only aggregates them — so a CI-only gate is bypassed by the `make rb-generate` a hand-edit is made next to. It is in check-targets as well, so a hand-edit that never regenerates is caught too. No order rule: the array's order is emission order, so it is content rather than formatting, and sorting it would reorder every generated method in seven SDKs.
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. |
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Loader path overrides and language-local generation targets can bypass the sole validator.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Centralizes generated-verbs.json validation while simplifying six generator loaders.
Changes:
- Adds a strict shared validator with comprehensive self-tests.
- Makes generation targets depend on the validator.
- Removes duplicated loader validation and obsolete tests.
[!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 | Description |
|---|---|
Makefile |
Wires validation into generation and checks. |
.github/workflows/test.yml |
Runs validator checks in CI. |
scripts/check-generated-verbs.rb |
Implements shared validation. |
scripts/test-check-generated-verbs.rb |
Tests valid and malformed declarations. |
scripts/test-generator-verb-inversion.rb |
Removes loader-validation cases. |
scripts/test-compiled-generator-refusal |
Removes malformed-declaration scenario. |
typescript/scripts/path-items.ts |
Simplifies TypeScript loading. |
python/scripts/gen_common.py |
Simplifies Python loading. |
ruby/scripts/generate-services.rb |
Simplifies Ruby service loading. |
ruby/scripts/generate-metadata.rb |
Simplifies Ruby metadata loading. |
kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/PathItems.kt |
Simplifies Kotlin loading. |
swift/Sources/BasecampGenerator/PathItems.swift |
Simplifies Swift loading. |
rust/generator/src/main.rs |
Simplifies Rust loading. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # It is an ORDER-ONLY PREREQUISITE of every *-generate target rather than a CI | ||
| # job, because there is no single generation entry point: ts-generate, | ||
| # rb-generate, py-generate, swift-generate, kt-generate-services and | ||
| # rs-generate-services are each directly invokable and `generate` only aggregates | ||
| # them, so a CI-only gate is bypassed by the `make rb-generate` that a hand-edit |
There was a problem hiding this comment.
Partly fixed in 3a5ecb0 — swift/Makefile and rust/Makefile call the gate directly now, and the release path goes through smithy-verify.yml.
Leaving this open with #discussion_r4051375698, because between them they are the finding: you named entry points five and six, that thread names the seventh (make py-check-drift, make rs-check-drift and their siblings run generators standalone), and there is no reason to think the enumeration terminates. This repo is built to have many paths, so "a prerequisite on every path" is fighting its shape — the same curve the loader predicates traced, one layer up.
Escalated with the question of whether the runtime input should exist at all rather than answered with an eighth prerequisite.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb3f63c565
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
Four bypasses of the one requirement the design rests on — that the validator runs, on the file actually used. The sharpest invalidated the gate outright: the checker read GENERATED_VERBS_FILE while the loaders read BASECAMP_GENERATED_VERBS, so `BASECAMP_GENERATED_VERBS=/tmp/bad.json make rb-generate` validated the committed file and then fed the generator the other one. A gate that validates a different artifact than the one consumed is not a gate. It honours the loaders' variable first now, and the self-test asserts it. swift/Makefile and rust/Makefile expose their own generate targets, which the root prerequisites never covered; both call the gate directly. The tag-triggered release workflows call neither test.yml nor the root targets, so the gate moved into smithy-verify.yml, the reusable workflow every release does call — which is the path where an unvalidated declaration would reach a published package. And it is bash + jq rather than Ruby. It runs in front of every generator, so it may only depend on what every contributor has: CONTRIBUTING lists bash and jq as shared tooling and Ruby as the Ruby SDK's own requirement, so a Ruby gate made `make ts-generate-services` fail with `ruby: command not found` for a TypeScript-only contributor.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3a5ecb0fa8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| echo "==> All checks passed" | ||
|
|
||
| check-targets: check-gradle-serialization test-check-gradle-serialization test-promote-migrating lint-actions sync-spec-version-check smithy-check smithy-mapper-test behavior-model-check provenance-check sync-api-version-check doc-constants-check url-routes-check catalog-check bc3-route-parity test-bc3-route-parity go-check-drift go-check-wrapper-drift go-check-generated-drift check-grouped-client-coverage test-check-grouped-client-coverage auth-routable-check check-service-inventory-parity test-check-service-inventory-parity check-operation-assignment-parity test-check-operation-assignment-parity kt-check-drift swift-check-drift rs-check-drift go-check ts-check rb-check kt-check swift-check py-check rs-check check-required-tags test-check-required-tags check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars go-check-optional-pointers test-enhance-request-reachability check-idempotency-parity check-write-semantics-parity check-retry-metadata-parity check-runner-test-reachability conformance check-fixture-execution check-replay-decoder-parity check-readme-env-vars test-check-readme-env-vars check-orphaned-doc-comments test-check-orphaned-doc-comments lint-npm-lockfile-writes test-lint-npm-lockfile-writes test-assert-sdk-built test-assert-lockfiles-unchanged check-projected-examples test-generator-verb-inversion | ||
| check-targets: check-gradle-serialization test-check-gradle-serialization test-promote-migrating lint-actions sync-spec-version-check smithy-check smithy-mapper-test behavior-model-check provenance-check sync-api-version-check doc-constants-check url-routes-check catalog-check bc3-route-parity test-bc3-route-parity go-check-drift go-check-wrapper-drift go-check-generated-drift check-grouped-client-coverage test-check-grouped-client-coverage auth-routable-check check-service-inventory-parity test-check-service-inventory-parity check-operation-assignment-parity test-check-operation-assignment-parity kt-check-drift swift-check-drift rs-check-drift go-check ts-check rb-check kt-check swift-check py-check rs-check check-required-tags test-check-required-tags check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars go-check-optional-pointers test-enhance-request-reachability check-idempotency-parity check-write-semantics-parity check-retry-metadata-parity check-runner-test-reachability conformance check-fixture-execution check-replay-decoder-parity check-readme-env-vars test-check-readme-env-vars check-orphaned-doc-comments test-check-orphaned-doc-comments lint-npm-lockfile-writes test-lint-npm-lockfile-writes test-assert-sdk-built test-assert-lockfiles-unchanged check-projected-examples test-generator-verb-inversion check-generated-verbs test-check-generated-verbs |
There was a problem hiding this comment.
Validate declarations before standalone drift checks
Adding the validator only to check-targets leaves directly invoked drift targets such as make py-check-drift and make rs-check-drift unguarded, even though those are prescribed as standalone pre-merge commands. With {"verbs":["get","post","put","delete",1]}, Python carries the number harmlessly and Rust drops it, so both regenerate the same output and report no drift despite the declaration being invalid; make each drift target that executes a generator depend on check-generated-verbs rather than relying on the aggregate make check.
AGENTS.md reference: AGENTS.md:L337-L341
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and not fixed — deliberately, because this is the seventh entry point found in two rounds and the count is now the finding.
The sequence: root *-generate targets → swift/Makefile and rust/Makefile generate → the release workflows → and now the standalone *-check-drift targets. Each one is real, each is closed by another prerequisite, and there is no reason to think this enumeration terminates: this repository is built to have many paths — six languages, six Makefiles, six CI jobs, seven release workflows — so "put a gate on every path" is fighting its shape. It is the same curve the loader predicates traced, one layer up: each round closed the instance and the class kept producing.
So this is escalated rather than patched, with the question being whether the runtime input should exist at all. If the verb set were baked into each generator from one definition — nothing loaded at runtime, no environment override, no file to hand-edit, no gate to run — every bypass in this thread and the four above ceases to exist rather than being closed one at a time. That has costs I have written up for the operator, including that three of the six languages have no build step to bake anything at.
Leaving this open, because it is the live one and the decision is not mine to make.
The precedence was backwards: GENERATED_VERBS_FILE, which exists only so the self-test can drive this script, took priority over the variable the six loaders actually honour. A valid GENERATED_VERBS_FILE therefore masked a malformed BASECAMP_GENERATED_VERBS and the gate passed immediately before the generator loaded the bad one — the invariant defeated by the gate's own plumbing, which is the same failure the variable mismatch was, one line further in. The self-test asserts the precedence directly rather than only asserting that each variable works on its own.
|
Head is 4dbec41. Five of seven threads resolved; two left open on purpose, and they are the ones that matter. Fixed
Open, and escalated rather than patched Seven entry points in two rounds: the root The question I have put to the operator is whether the runtime input should exist at all. If the verb set were baked into each generator from one definition, every bypass in these threads ceases to exist rather than being closed. The honest complication is that only three of the six languages have a build step to bake anything at — Ruby, Python and TypeScript would need a generated source file and a drift check, which is a real gate but a different design. That trade is written up for the operator; it is not mine to decide, and I have stopped adding prerequisites until it comes back. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The central gate accepts multi-document JSON and is not consistently ordered against or aligned with every generator invocation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 3
Open (14)
Drift targets can bypass or race the validator · New Inconsistent verb-file override across validators and generators · New Enforce generated verbs validation across all generation entry points Validator accepts multiple top-level JSON values · New Validator path incorrectly includes .rb extension · New Docstring references nonexistent .rb validator · New Validator path incorrectly includes .rb extension · New Validator path incorrectly includes .rb extension · New Validator path incorrectly includes .rb extension · New Self-test references nonexistent .rb validator · New Validator path incorrectly includes .rb extension · New Validator path incorrectly includes .rb extension · New Validator path incorrectly includes .rb extension · New Validator path incorrectly includes .rb extension · New
Resolved since last review (1)
| echo "==> All checks passed" | ||
|
|
||
| check-targets: check-gradle-serialization test-check-gradle-serialization test-promote-migrating lint-actions sync-spec-version-check smithy-check smithy-mapper-test behavior-model-check provenance-check sync-api-version-check doc-constants-check url-routes-check catalog-check bc3-route-parity test-bc3-route-parity go-check-drift go-check-wrapper-drift go-check-generated-drift check-grouped-client-coverage test-check-grouped-client-coverage auth-routable-check check-service-inventory-parity test-check-service-inventory-parity check-operation-assignment-parity test-check-operation-assignment-parity kt-check-drift swift-check-drift rs-check-drift go-check ts-check rb-check kt-check swift-check py-check rs-check check-required-tags test-check-required-tags check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars go-check-optional-pointers test-enhance-request-reachability check-idempotency-parity check-write-semantics-parity check-retry-metadata-parity check-runner-test-reachability conformance check-fixture-execution check-replay-decoder-parity check-readme-env-vars test-check-readme-env-vars check-orphaned-doc-comments test-check-orphaned-doc-comments lint-npm-lockfile-writes test-lint-npm-lockfile-writes test-assert-sdk-built test-assert-lockfiles-unchanged check-projected-examples test-generator-verb-inversion | ||
| check-targets: check-gradle-serialization test-check-gradle-serialization test-promote-migrating lint-actions sync-spec-version-check smithy-check smithy-mapper-test behavior-model-check provenance-check sync-api-version-check doc-constants-check url-routes-check catalog-check bc3-route-parity test-bc3-route-parity go-check-drift go-check-wrapper-drift go-check-generated-drift check-grouped-client-coverage test-check-grouped-client-coverage auth-routable-check check-service-inventory-parity test-check-service-inventory-parity check-operation-assignment-parity test-check-operation-assignment-parity kt-check-drift swift-check-drift rs-check-drift go-check ts-check rb-check kt-check swift-check py-check rs-check check-required-tags test-check-required-tags check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars go-check-optional-pointers test-enhance-request-reachability check-idempotency-parity check-write-semantics-parity check-retry-metadata-parity check-runner-test-reachability conformance check-fixture-execution check-replay-decoder-parity check-readme-env-vars test-check-readme-env-vars check-orphaned-doc-comments test-check-orphaned-doc-comments lint-npm-lockfile-writes test-lint-npm-lockfile-writes test-assert-sdk-built test-assert-lockfiles-unchanged check-projected-examples test-generator-verb-inversion check-generated-verbs test-check-generated-verbs |
There was a problem hiding this comment.
Correct, and it is the eighth entry point.
check-targets lists the validator as a sibling, so make -j can schedule it concurrently with the drift checks, and make ts-check-drift or make rs-check-drift directly bypasses it. The sequence so far: root *-generate targets, swift/Makefile, rust/Makefile, the release workflows, the standalone drift targets, and now ordering within check-targets itself.
I am not adding a ninth prerequisite. This repository is built to have many paths — six languages, six Makefiles, six CI jobs, seven release workflows — so a gate on every path is fighting its shape, and the count is the argument rather than any single instance. Together with the two JSON-semantics findings above, that is the case for removing the runtime input instead of guarding it.
Open pending the operator's call.
| # BASECAMP_GENERATED_VERBS first: it is what the LOADERS honour, so the gate has | ||
| # to validate whatever they are about to read. GENERATED_VERBS_FILE is the | ||
| # self-test's own handle on this script. | ||
| VERBS_FILE="${GENERATED_VERBS_FILE:-${BASECAMP_GENERATED_VERBS:-$ROOT_DIR/spec/generated-verbs.json}}" |
| if ! jq -e . "$VERBS_FILE" > /dev/null 2>&1; then | ||
| jq . "$VERBS_FILE" 2>&1 | head -3 | sed 's/^/it is not valid JSON: /' | die |
There was a problem hiding this comment.
Correct, and deliberately not fixed — this finding and the BOM one are what settle the design question this PR is really asking, so patching them would bury the evidence.
jq accepting a stream of top-level values, and jq accepting a UTF-8 BOM that Python's json.loads and TypeScript's JSON.parse reject, both show the same thing: the validator is a seventh implementation with its own JSON semantics. It cannot define validity, because "valid JSON" is not one thing across seven parsers — it can only assert its own reading and hope the other six agree.
That is the premise of "one validator, six readers" failing on its own terms. A slurp check and a BOM check would be predicates six and seven of the exact class this design was built to end: Kotlin's JsonPrimitive coercion, Rust's filter_map, Ruby's ASCII strip, Swift's allSatisfy, JavaScript's $. Adding two more would make the count seven and the lesson zero.
Left open on purpose. The structural decision is with the operator, between baking the verb set into each generator at build time — nothing loaded, nothing to validate, no parser to disagree — and accepting a gate that cannot be made airtight. This thread is evidence for that call, not a queue item.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4dbec418a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| if ! jq -e . "$VERBS_FILE" > /dev/null 2>&1; then | ||
| jq . "$VERBS_FILE" 2>&1 | head -3 | sed 's/^/it is not valid JSON: /' | die |
There was a problem hiding this comment.
Reject JSON forms that the generators cannot parse
This jq validity check accepts inputs that the generator parsers reject. For example, a declaration prefixed with a UTF-8 BOM passes this check and the subsequent shape validation, but Python's json.loads and TypeScript's JSON.parse reject it; similarly, jq accepts concatenated top-level JSON documents as a stream while those consumers reject the extra data. In either case make check-generated-verbs reports success and the generation pipeline later fails, so require exactly one consumer-compatible JSON document and cover these cases in the self-test.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and deliberately not fixed — this finding and the BOM one are what settle the design question this PR is really asking, so patching them would bury the evidence.
jq accepting a stream of top-level values, and jq accepting a UTF-8 BOM that Python's json.loads and TypeScript's JSON.parse reject, both show the same thing: the validator is a seventh implementation with its own JSON semantics. It cannot define validity, because "valid JSON" is not one thing across seven parsers — it can only assert its own reading and hope the other six agree.
That is the premise of "one validator, six readers" failing on its own terms. A slurp check and a BOM check would be predicates six and seven of the exact class this design was built to end: Kotlin's JsonPrimitive coercion, Rust's filter_map, Ruby's ASCII strip, Swift's allSatisfy, JavaScript's $. Adding two more would make the count seven and the lesson zero.
Left open on purpose. The structural decision is with the operator, between baking the verb set into each generator at build time — nothing loaded, nothing to validate, no parser to disagree — and accepting a gate that cannot be made airtight. This thread is evidence for that call, not a queue item.
| # unset; giving the test variable precedence (as the first version of this line | ||
| # did) let a valid GENERATED_VERBS_FILE mask a malformed BASECAMP_GENERATED_VERBS | ||
| # and pass the gate immediately before the generator loaded the bad one. | ||
| VERBS_FILE="${BASECAMP_GENERATED_VERBS:-${GENERATED_VERBS_FILE:-$ROOT_DIR/spec/generated-verbs.json}}" |
There was a problem hiding this comment.
Align the override with the compiled generator inputs
When BASECAMP_GENERATED_VERBS is set for make kt-generate-services, swift-generate, or rs-generate-services, this line validates the override, but those recipes do not pass --verbs; their Kotlin, Swift, and Rust entry points therefore still read their committed default paths. A valid override can consequently let the gate pass while the compiled generator consumes an unvalidated malformed file, and an invalid override can block a generator that would never read it. Fresh evidence beyond the earlier override comment is that these three loaders do not consult this environment variable at all, so propagate the selected path through their CLI arguments or validate their actual input instead.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and not fixed for the same reason as #discussion_r4051461976.
The precise shape: Ruby, Python and TypeScript honour BASECAMP_GENERATED_VERBS, so the gate follows it to stay pointed at the file those loaders will read. Kotlin, Swift and Rust take --verbs or their default path and their recipes do not forward the variable — so with it set, the gate validates one file and three generators read another.
Closing this means either teaching three recipes to forward an environment variable or removing the override, and both are more mechanism in a design whose premise is already in question above. The override exists so self-tests can point loaders at crafted declarations; a design with no runtime input needs neither the override nor the gate.
Open pending the operator's call.
The gate was written as Ruby, rewritten as bash and jq so it could run in front of every generator without putting Ruby in a TypeScript-only contributor's way, and the executable lost its extension in the move. Eleven docstrings across six languages and three scripts kept pointing at scripts/check-generated-verbs.rb, a file that does not exist. Every one of them is a reference a reader would follow and find nothing at, which is the same stale-claim shape as a count that no longer matches its loop — here eleven times from one rename. Worth seeing as a number rather than as eleven separate corrections.
|
Head is 12c1a0f. Eleven stale references fixed; four findings answered and left open on purpose. Fixed: the gate was written in Ruby, rewritten in bash + jq so it could run in front of every generator without putting Ruby in a TypeScript-only contributor's way, and the executable lost its Open, because they are the answer to the question this PR exists to ask. Review found that the premise does not hold:
So the validator is a seventh implementation with its own JSON semantics. It cannot define validity, because "valid JSON" is not one thing across seven parsers. Writing the slurp check and the BOM check would make them predicates six and seven of the exact class this design was built to end — after Kotlin's The other two are the same story from the other side: the The body now records all of this, plus the alternative (bake the verb set in at build time — nothing loaded, no override, no gate, every bypass gone) and a third option I would not have raised at the start: the verb set is four strings that have never changed, #925's exposure was latent, and the defect it actually named — discovery by verb list — is fixed in #931 and stays fixed however this is decided. What remains is an emission bound, and six copies of one cannot silently drop an operation: each refuses by name, and This PR is open as evidence, not as something I am asking to merge. The call is with the operator. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12c1a0f0eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| # unset; giving the test variable precedence (as the first version of this line | ||
| # did) let a valid GENERATED_VERBS_FILE mask a malformed BASECAMP_GENERATED_VERBS | ||
| # and pass the gate immediately before the generator loaded the bad one. | ||
| VERBS_FILE="${BASECAMP_GENERATED_VERBS:-${GENERATED_VERBS_FILE:-$ROOT_DIR/spec/generated-verbs.json}}" |
There was a problem hiding this comment.
Preserve empty loader overrides in the validator
When BASECAMP_GENERATED_VERBS is present but empty, :- treats it as unset, so the gate validates the committed declaration and exits successfully, while the Ruby, Python, and TypeScript loaders honor the empty value as a path and fail afterward (for example, BASECAMP_GENERATED_VERBS='' make py-generate-services). Fresh evidence beyond the prior override-precedence finding is this set-but-empty case; use the shell expansion that distinguishes unset from empty, or make every loader apply the same fallback semantics.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
|
Closing this rather than landing it. The premise — one validator in front of six loaders — fails on its own terms, and this PR is what established that. Landing it would ship a gate that does not gate. The open threads here are not a backlog — they are the evidence, and they have done their work. The resolution is recorded on #935: keep Not urgent, and the reason is worth stating: the exposure is latent — no operation in the committed spec uses a verb outside the five — and the file is a generation-time input that no shipped SDK source reads. The eleven stale |
- Cite #935 (policy transcribed once per language), not #926 (the merged catalog PR), for the 'generators can never disagree' argument; keep #925 as the original failure. - Fix the loadGeneratedVerbs rationale: no upstream validator exists (#933 closed unmerged and proved a 'prerequisite of every generate target' unreachable), so fail-loud IS the contract — not a reason to relax the read. - Drop the nonexistent 'make check-generated-verbs' target from the no-usable-verb error; point at the verbs array in the file instead. - Say why defaultGeneratedVerbs is a constant, not a flag (an overridable bound produced two of #933's findings).
) * Derive gen-catalog's emission bound from spec/generated-verbs.json gen-catalog carried its own five-verb allow-list — a known copy of the same fact six SDK generators already read from spec/generated-verbs.json, which is exactly the divergence #925 was. Replace the hardcoded map with a permissive derive from that single declaration (basecamp-sdk#926): - loadGeneratedVerbs reads the file with encoding/json, keeps the non-empty/non-whitespace verb strings, and FAILS LOUD on an unreadable or unparseable file AND on a file that yields no usable verb (verbs absent, [], or all blank). No fallback to a hardcoded default: a silent fallback is how a stale superset (e.g. one still naming patch) survives the file dropping it, so an empty bound stops the build rather than reading as "no restriction." No downstream len==0 short-circuit turns an empty bound into allow-everything. - The read is one isolated function so the #935 switch to a generated Go constant is a one-function swap, not a rewrite. - Build takes the bound as a parameter; the exclusion-based path-item slot discovery and the fail-loud-on-out-of-bound-verb behavior are unchanged, now bounded by the file rather than the literal. Post-#932 the file no longer lists patch, so the derived bound differs from the old literal only by patch (unused) and catalog.json regenerates byte-identical. Also fixes the stale header comment (it still named the five hardcoded verbs) and updates the not_patch note in generated-verbs.json, whose last sentence described gen-catalog as carrying a superset — now false, since the bound is identical by construction. * Correct gen-catalog derive comments and no-verb error - Cite #935 (policy transcribed once per language), not #926 (the merged catalog PR), for the 'generators can never disagree' argument; keep #925 as the original failure. - Fix the loadGeneratedVerbs rationale: no upstream validator exists (#933 closed unmerged and proved a 'prerequisite of every generate target' unreachable), so fail-loud IS the contract — not a reason to relax the read. - Drop the nonexistent 'make check-generated-verbs' target from the no-usable-verb error; point at the verbs array in the file instead. - Say why defaultGeneratedVerbs is a constant, not a flag (an overridable bound produced two of #933's findings).



Stacked on #932 — merge that first. It carries a data-loss fix that should not wait on this, and this PR's diff is only the commit on top.
The class
The six generators each read
spec/generated-verbs.json, and each of them also validated it. They disagreed about what it meant five times in four review rounds, every one in a different language for a different reason, and every one on invalid input:JsonPrimitive.contentrenders a number as text, so["get", 1]read asget/1filter_mapdropped the non-string and accepted the restString#stripis ASCII-only, so U+00A0 and U+2003 survived it where Python'sstr.stripremoved themString.allSatisfyis vacuously true for"", so the empty string passed a character-class check$matches before a final line terminator, so"get\n"passed the same checkTwo repairs were tried and neither held.
[a-z]+format rule, on the reasoning that defining the allowed set makes whitespace, case, BOMs and normalisation moot. That moved it again, to "what does this language's predicate actually match".The second was a deliberate call and it was wrong, and the way it was wrong is the useful part: it did not narrow the population, it relocated it. The bound was never the rule being expressed. It is the union of six standard libraries' string and regex semantics, which is why every round found another instance — and Kotlin's was in the parse rather than in a check, so "validate more carefully" could not have reached it either.
The shape
One implementation rejects a malformed declaration. The six loaders take the strings verbatim.
scripts/check-generated-verbs.rbis that implementation. It is strict enough that valid input has no interpretive room left: a non-empty array of distinct lowercase ASCII tokens, and no unexpected top-level keys (soverbforverbsis questioned rather than silently ignored while the real key is absent). Every JSON parser in the six languages agrees about a file like that — which is the whole point, because all five divergences happened on input this gate now makes unreachable.The loaders perform no validation, not less of it. Each is a parse and a field read, with a failure message pointing at the gate. Anything left behind would be another chance to disagree.
No order rule, deliberately. The array is ordered and that order is emission order in every generator, so it is content, not formatting: sorting it would reorder every generated method in seven SDKs, and there is nothing to canonicalise because the file is declaring the order rather than spelling a set in an arbitrary sequence. Determinism, which a canonical form usually buys, a JSON array already gives.
Why a prerequisite and not a CI job
This is the part that decides whether the mechanism works, and the Makefile settles it: there is no single generation entry point.
ts-generate,ts-generate-services,rb-generate,rb-generate-services,py-generate,py-generate-services,kt-generate-services,swift-generateandrs-generate-servicesare each directly invokable, andgeneratemerely aggregates them. A CI-only validator is bypassed by anyone runningmake rb-generatelocally — which is exactly where a hand-edit to the declaration gets made.So it is an order-only prerequisite of all nine, and also a member of
check-targets, so a hand-edit that never regenerates anything is caught too. Order-only because it is a gate rather than a dependency: nothing in those recipes consumes its output.The self-test is the argument
scripts/test-check-generated-verbs.rb, 18 cases. The negative ones are not invented — each is a shape that actually told the six loaders apart over those four rounds:1," "," "," ","","get\n","GET", and a duplicate. Every one of them now dies once, in one language, before any generator runs.Its positive control includes a non-alphabetical order, which pins the no-order-rule decision rather than leaving it to a comment.
What moved rather than disappeared
scripts/test-generator-verb-inversion.rband the compiled harness. Asserting them against a loader now would assert the absence of a check, which is the point rather than a regression. The half that remains is the one that still matters: each loader takes its bound from the declaration rather than from a private literal, pinned by the scenario that withholds a verb the spec really uses.scripts/gen-catalog/main.goreads no declaration — it keeps its own deliberately looser superset bound, is owned elsewhere, and is untouched here. This gate only constrains the file, so a looser reader of a file this strict cannot be made wrong by it.Verification
make checkgreen end to end; all seven SDKs regenerate byte-identical. The validator passes on the committed declaration and fails on all 13 negative shapes with the offending index named.Why this may not be buildable, found in reviewing it
The design is one validator, six readers: one implementation defines validity strictly enough that the six loaders can parse without interpreting. Review found two things that falsify the premise rather than the implementation.
jqaccepts a stream of top-level JSON values, so the gate never establishes the file is one document. Two consecutive objects each carryingverbspass here while the six loaders reject or read them differently.jqaccepts a UTF-8 BOM that Python'sjson.loadsand TypeScript'sJSON.parsereject.The validator is therefore a seventh implementation with its own JSON semantics. It cannot define validity, because "valid JSON" is not one thing across seven parsers — it can only assert its own reading and hope the other six agree. A slurp check and a BOM check would be predicates six and seven of exactly the class this design was built to end, after Kotlin's
JsonPrimitivecoercion, Rust'sfilter_map, Ruby's ASCIIstrip, Swift'sallSatisfyand JavaScript's$.Alongside that, the "the validator actually runs" requirement has now needed eight entry points: the root
*-generatetargets,swift/Makefile,rust/Makefile, the release workflows, the standalone*-check-drifttargets, and ordering withincheck-targetsitself. Each is real and each is closable. The count is the argument.The alternative is to remove the runtime input: bake the verb set into each generator at build time from one definition, so there is nothing to load, no environment override, no file to hand-edit and no gate to run. Every bypass above ceases to exist rather than being closed. The honest complication is that only three of six languages have a build step to bake at — Ruby, Python and TypeScript would need a generated source file plus a drift check, which the repo already has the pattern for but is a different design.
And a third option worth weighing, which I would not have raised at the start. The verb set is four strings that have not changed in this project's life, and #925's exposure was latent — no operation in the spec uses a verb outside the list, which is why the hole survived unnoticed. The defect #925 actually named was discovery by verb list, and that is fixed in #931 and stays fixed however this is decided. What remains is an emission bound, and six copies of an emission bound cannot silently drop an operation: each refuses loudly by name, and
check-operation-assignment-paritycatches any cross-SDK divergence. It is worth asking whether the shared declaration now costs more than the duplication it removed.The stale-filename findings in this PR are fixed (11 references left behind by renaming the gate from Ruby to bash+jq). The four above are answered and left open on purpose.