fix(core): key generate-spec method schemas by namespace (fixes #445) - #498
fix(core): key generate-spec method schemas by namespace (fixes #445)#498osama-rizk wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 7418d35 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| // want the JSDoc tag to be honored, so the AST-only scan acts as | ||
| // a safety net. | ||
| if (tsTypes.get(methodName)?.skipCodegen || skipCodegenNames.has(methodName)) continue; | ||
| if (tsInfo?.skipCodegen || skipCodegenNames.has(methodName)) continue; |
There was a problem hiding this comment.
skipCodegenNames holds bare method names (from extractSkipCodegenMethods), so this has(methodName) check isn't namespace-aware. If two namespaces both expose create and only one is tagged @blocksSkipCodegen, doesn't the other one get dropped from the spec too? That looks like the same collision #445 is about, just hiding in this safety-net branch. Could we qualify the skip set the same way and check has(qualifiedName) || has(methodName)?
There was a problem hiding this comment.
Great catch — real bug, fixed. extractSkipCodegenMethods now keys namespace-qualified too (bare only when the namespace is unattributable), and generate-spec checks skipCodegenNames.has(qualifiedName) || has(methodName). So a @blocksSkipCodegen tag on widgets.create no longer drops subscriptions.create. Added a regression test asserting exactly that (tagged widgets.create skipped, untagged subscriptions.create survives, no bare create key).
| // name for entries the extractor couldn't attribute to a namespace. | ||
| // Qualified keys stop two namespaces that share a method name (e.g. | ||
| // widgets.create / subscriptions.create) from cross-assigning schemas. | ||
| const tsInfo = tsTypes.get(qualifiedName) ?? tsTypes.get(methodName); |
There was a problem hiding this comment.
Nice fallback. Worth a quick note that two namespaces the extractor can't attribute (default export, factory-wrapped) still land on bare keys and could overwrite each other here, same as #445. Fine to leave as-is, but a one-line comment would save the next person some head-scratching.
There was a problem hiding this comment.
Done — added a sentence to the fallback comment noting that namespaces the extractor can't attribute (default export, factory-wrapped, export-rename) still land on bare keys and could overwrite each other there, same class as #445.
| function enclosingBindingName(node: ts.Node): string | undefined { | ||
| let current: ts.Node | undefined = node.parent; | ||
| while (current) { | ||
| if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text; |
There was a problem hiding this comment.
Small edge case: this keys off the const binding name, but generate-spec looks methods up by the export routing name. For const widgets = ...; export { widgets as gadgets } the keys won't line up and the types quietly fall back to unknown. Rare, and the old bare-keying happened to dodge it. Maybe just a comment flagging the limitation?
There was a problem hiding this comment.
Added a comment on enclosingBindingName flagging it: it keys by the local const binding, so an export { widgets as gadgets } rename keys widgets.* while routing looks up gadgets.* → qualified lookup misses and falls back to the bare key (resolves if no collision, else unknown). Left resolving the alias as a noted follow-up since it's rare and the fallback is safe.
| try { | ||
| const types = extractMethodTypes(join(dir, 'index.ts')); | ||
| // Distinct qualified keys — no collision. | ||
| assert.ok(types.has('widgets.create'), 'widgets.create should be keyed by namespace'); |
There was a problem hiding this comment.
Good repro — this fails on main. Since the fix also changed how generate-spec looks these keys up, would it be worth a matching collision test on the generate-spec side? Two namespaces with a same-named method taking different params, asserting each emitted method keeps its own schema. That's the part customers actually hit in #445.
There was a problem hiding this comment.
Added it. New generateSpec regression test: two namespaces each with a create taking different params (string vs number), asserting the emitted widgets.create keeps label: string and subscriptions.create keeps topicCount: number — the user-visible #445 in the OpenRPC output, not just the map. (It feeds a typed .ts foundation to the extractor and a custom loader for runtime discovery, since node --test can't import a raw .ts.)
| // want the JSDoc tag to be honored, so the AST-only scan acts as | ||
| // a safety net. | ||
| if (tsTypes.get(methodName)?.skipCodegen || skipCodegenNames.has(methodName)) continue; | ||
| if (tsInfo?.skipCodegen || skipCodegenNames.has(methodName)) continue; |
There was a problem hiding this comment.
[minor] skipCodegenNames.has(methodName) here is still keyed by the bare method name. tsInfo above is now namespace-resolved, but this safety-net check is not — so if two namespaces share a method name and only one carries @blocksSkipCodegen, the other can be wrongly dropped from the spec. That's the same #445 collision class, just in the fallback branch. Consider qualifying the skip set and checking has(qualifiedName) || has(methodName).
There was a problem hiding this comment.
Great catch — real bug, fixed. extractSkipCodegenMethods now keys namespace-qualified too (bare only when the namespace is unattributable), and generate-spec checks skipCodegenNames.has(qualifiedName) || has(methodName). So a @blocksSkipCodegen tag on widgets.create no longer drops subscriptions.create. Added a regression test asserting exactly that (tagged widgets.create skipped, untagged subscriptions.create survives, no bare create key).
| function enclosingBindingName(node: ts.Node): string | undefined { | ||
| let current: ts.Node | undefined = node.parent; | ||
| while (current) { | ||
| if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text; |
There was a problem hiding this comment.
[minor] enclosingBindingName keys off the const binding name, but generate-spec routes by the export name. An export rename — const widgets = new ApiNamespace(...); export { widgets as gadgets } — keys widgets.* while routing looks up gadgets.*, quietly falling back to the bare/unknown path. Worth a one-line comment flagging the limitation (or resolving the export alias if it's cheap).
There was a problem hiding this comment.
Added a comment on enclosingBindingName flagging it: it keys by the local const binding, so an export { widgets as gadgets } rename keys widgets.* while routing looks up gadgets.* → qualified lookup misses and falls back to the bare key (resolves if no collision, else unknown). Left resolving the alias as a noted follow-up since it's rare and the fallback is safe.
| try { | ||
| const types = extractMethodTypes(join(dir, 'index.ts')); | ||
| // Distinct qualified keys — no collision. | ||
| assert.ok(types.has('widgets.create'), 'widgets.create should be keyed by namespace'); |
There was a problem hiding this comment.
[minor] This proves the extractor produces distinct qualified keys — nice repro. Consider also adding a collision test on the generate-spec side: two namespaces, same-named method, different params, asserting each operation keeps its own schema in the generated OpenRPC spec. That locks in the actual user-visible #445 (which manifested in the spec, not the map).
There was a problem hiding this comment.
Added it. New generateSpec regression test: two namespaces each with a create taking different params (string vs number), asserting the emitted widgets.create keeps label: string and subscriptions.create keeps topicCount: number — the user-visible #445 in the OpenRPC output, not just the map. (It feeds a typed .ts foundation to the extractor and a custom loader for runtime discovery, since node --test can't import a raw .ts.)
| // name for entries the extractor couldn't attribute to a namespace. | ||
| // Qualified keys stop two namespaces that share a method name (e.g. | ||
| // widgets.create / subscriptions.create) from cross-assigning schemas. | ||
| const tsInfo = tsTypes.get(qualifiedName) ?? tsTypes.get(methodName); |
There was a problem hiding this comment.
[nit] Good fallback comment. One added sentence noting that namespaces the extractor can't attribute (default export, factory-wrapped) still land on bare keys and could overwrite each other would flag the residual edge for the next reader.
There was a problem hiding this comment.
Done — added a sentence to the fallback comment noting that namespaces the extractor can't attribute (default export, factory-wrapped, export-rename) still land on bare keys and could overwrite each other there, same class as #445.
…s-assignment (#445) extractMethodTypes keyed schemas by bare method name, so two ApiNamespaces with a shared method name (widgets.create / subscriptions.create) cross-assigned schemas in the OpenRPC output. Key by namespace-qualified name (derived from the binding, matching generate-spec's routingName); generate-spec looks up qualified-then-bare so unattributed methods still resolve. Updated extractor tests to qualified keys + added a #445 regression test.
…test (review on #498) Address sarayev/soberm review: - The @blocksSkipCodegen safety-net check was still bare-keyed, so a tag on one namespace's method would drop another namespace's same-named method from the spec (same #445 collision, in the fallback branch). extractSkipCodegenMethods now keys namespace-qualified (bare only when unattributable); generate-spec checks has(qualifiedName) || has(methodName). - Comment the residual bare-key edge (default export / factory-wrapped) and the export-alias limitation of enclosingBindingName (const binding vs export name). - Tests: generate-spec-side #445 regression (two namespaces, same method, distinct params → each keeps its own schema in the OpenRPC output) + a skipCodegen collision test; updated existing skip test to qualified keys.
cb21537 to
7418d35
Compare
Fixes #445.
Problem
blocks-generate-spec's TypeScript extractor (extract-ts-types.ts) keyed each method's param/result schemas by the bare method name (result.set(methodName, info)). When twoApiNamespaces each expose an operation with the same name —widgets.createandsubscriptions.create— the second overwrote the first in the map, sogenerate-speclooked uptsTypes.get('create')and silently assigned one namespace's schemas to the other in the OpenRPC document. Their TS contracts are deliberately incompatible, so the generated client types were wrong with no error.Fix
Key method schemas by the namespace-qualified name (
namespace.method), derived from the enclosingexport const <name> = new ApiNamespace(...)binding — the same namegenerate-specroutes by (routingName = exportName).generate-specnow resolvestsTypes.get(qualifiedName) ?? tsTypes.get(methodName): qualified wins (no collision), with a bare fallback so any method the extractor couldn't attribute to a namespace (e.g. created behind indirection) still resolves exactly as before — no previously-working case regresses.Covers both extractor paths (the direct
new ApiNamespace(...)AST walk and the type-checker-resolved indirect path).Testing
extract-ts-types.test.ts): two namespaces both exposingcreatewith incompatible params → distinctwidgets.create/subscriptions.createentries, each keeping its own param type, and no barecreateentry.generate-spec+generate-clientsuites green.Verification
npm run lint0 errors. No public API change (internal codegen). Changeset added.Changeset
@aws-blocks/corepatch +@aws-blocks/blockspatch (umbrella re-export).