feat(js): service-scoped type resolution + generic substitution on TypeResolver#1323
feat(js): service-scoped type resolution + generic substitution on TypeResolver#1323
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces service-scoped type resolution and generic type substitution for the Sails IDL v2 implementation. Key enhancements include the addition of resolveInService to SailsProgram for scoped type lookups and the introduction of ambientTypes in SailsService to support program-level types. The TypeResolver has been significantly updated with logic for shadowing ambient types, recursive generic substitution with cycle detection, and helper methods for mapping type parameters. Feedback was provided regarding the performance of resolveInService, suggesting the use of Maps for
- Build a lazy `Map<serviceName, Map<typeName, Type>>` index in `SailsProgram` on first `resolveInService` call. O(1) per call after that, no cost for consumers who never use the method. Addresses the gemini-code-assist comment on sails-idl-v2.ts:242. - Swap `JSON.parse(JSON.stringify(input))` in the "does not mutate" test for `structuredClone(input)` to satisfy `unicorn/prefer-structured-clone` (fixes the lint job failure on the v1.6.1.0 JS CI).
…peResolver Closes #1317. Adds three public methods on `TypeResolver` and a convenience accessor on `SailsProgram` so downstream consumers (wallets, explorers, custom codegen) stop re-implementing type-scope + generic-substitution out-of-band. - `TypeResolver.resolveNamed(typeDecl)` — look up a named user `Type` from the resolver's scope, honoring ambient-vs-local shadowing. - `TypeResolver.substituteGenerics(typeDecl, substitutions)` — pure, idempotent tree walk that substitutes bare `{kind:'named', name:'T'}` leaves through chains (`{T: U, U: u32}` resolves `T` to `u32`). Runtime cycle guard on self-referential or cyclic maps. Same-reference return on unchanged subtrees avoids pointless allocation in walkers. - `TypeResolver.genericsSubstitutions(userType, generics)` — zip a user type's `type_params` with a concrete generics list into a sub map. `getTypeDeclString` now uses this helper instead of an inline copy. - `TypeResolver` constructor gained an `ambientTypes` parameter; `SailsProgram` threads `program.types` into every `SailsService` (including the `extends` chain) so service-local types shadow program-level ambients inside each per-service resolver. - `SailsProgram.resolveInService(serviceName, typeDecl)` — direct AST lookup that skips `SailsService`/`TypeResolver` construction, safe to call in tight walker loops. Tests: 21 new (17 unit + 4 parser-driven integration) covering all five `TypeDecl` variants, shadowing with registry-level collision, chain resolution, cycle detection, unknown-kind rejection, and service-scoped collision across two services declaring the same type name with incompatible shapes.
- Build a lazy `Map<serviceName, Map<typeName, Type>>` index in `SailsProgram` on first `resolveInService` call. O(1) per call after that, no cost for consumers who never use the method. Addresses the gemini-code-assist comment on sails-idl-v2.ts:242. - Swap `JSON.parse(JSON.stringify(input))` in the "does not mutate" test for `structuredClone(input)` to satisfy `unicorn/prefer-structured-clone` (fixes the lint job failure on the v1.6.1.0 JS CI).
…cl::Generic Per maintainer feedback on #1323: - `TypeResolver` constructor reverts to `(types: Type[])`. Callers that want ambient-vs-local shadowing pass `[...ambientTypes, ...localTypes]` — the merge is the caller's concern, not the resolver's. Moved the merge to `SailsService`, which is the only caller that needs it. - `substituteGenerics` and `resolveNamed` now target the explicit `TypeDecl::Generic` AST variant (landed in #1314). A bare `{kind:'named'}` decl is always a concrete user-type reference and is no longer treated as a type-parameter fallback; substitution only fires on `{kind:'generic'}` leaves. Cycle detection, chain resolution, and same-reference returns on unchanged subtrees all unchanged. - Tests updated to use `{kind:'generic', name:'T'}` for type parameters. Ambient-types describe block renamed to "last-write-wins merge (shadowing via call-site merge)" to reflect the new API shape.
8155c74 to
dd74a92
Compare
|
Rebased on master and addressed maintainer feedback:
Cycle detection, chain resolution ( |
Graph review surfaced SailsService.extends as a test gap — it propagates ambient types to child services at sails-idl-v2.ts:612, but no offline test exercised that path. Adds a parser-driven test: Child extends Base, program declares Shared, verify baseThroughExtends.typeResolver resolves Shared through the propagated ambient types.
…solveGenerics
- Rename `substituteGenerics` → `resolveGenerics`.
- Hide `genericsSubstitutions` (now `_genericsSubstitutions`). Consumers no longer
manually zip `type_params` with a `TypeDecl.generics` list — that's resolver logic.
- Extend `resolveNamed` with a second overload `(name: string, generics?: TypeDecl[])`.
When concrete generics are provided (via either overload or via `typeDecl.generics`),
the result is a concrete substituted `Type` with `type_params` stripped:
`Envelope<u32>` → `{ kind: 'struct', fields: [{id, u32}, {payload, u32}] }`.
Without generics, the raw user `Type` is returned unchanged.
Per vobradovich feedback on #1323.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #1317.
Summary
Adds three public methods on
TypeResolverand a convenience accessor onSailsProgramso downstream consumers (wallets, explorers, schema tooling, custom codegen) stop re-implementing type-scope and generic-substitution out-of-band.New API
SailsProgramnow threadsprogram.typesinto everySailsService(including theextendschain) as ambient types, so per-serviceTypeResolverinstances see program-level types with service-local shadowing.getTypeDeclStringwas also updated to call the newgenericsSubstitutionshelper instead of the inline copy at lines 229-233.Why
Two correctness bugs in consumer code before this change:
Packetwith different shapes silently collided when a consumer flattened types into a single map. No public API narrowed a lookup to a single service's scope.Envelope<[u8]>'spayloadfield came through as a bare{kind:'named', name:'T'}leaf, causing walkers to silently pass hex strings through untouched or SCALE-encode with wrong bytes. The substitution algorithm already lived insidegetTypeDeclString— this PR factors it out and exposes it.vara-wallet had ~180 lines of workaround (
coerceHexToBytesV2,getRegistryTypes, ad-hoc substitution recursion) that can now collapse to a few method calls.Tests
21 new tests, 46 total passing in the resolver + parser-resolver suites (64 total offline).
Unit (
js/test/idl-v2-type-resolver.test.ts):substituteGenerics: bare-param substitution, recursion through slice/array/tuple/named-with-generics, pass-through for unknown names, idempotence, non-mutation, chain resolution (T → U → u32), self-referential throw, cyclic chain throw, unknown-kind throw.resolveNamed: returns userTypefor named decl (including generic), returnsundefinedfor primitives/slices/arrays/tuples/unknown names/bare type_params.genericsSubstitutions: zip, no type_params, extra generics truncated.[u8; 8]after overriding an[u8; 4]ambient shape).Integration (
js/test/idl-v2-parser-type-resolver.test.ts):Packetwith different shapes →resolveInService('A', ...)vs'B'return their own definitions.undefined.Sharedvisible via service resolver (ctor arg path; the parser rejects program-level references from service signatures).Envelope<[u8]>→ walker feeds struct fields throughsubstituteGenerics+genericsSubstitutionsto resolvepayloadfromTto[u8].Notes for reviewers
substituteGenericshas a runtime cycle guard (visited set, throws with the full chain on detection) rather than just the JSDoc warning. Maps produced bygenericsSubstitutionsfrom parsed IDL can't produce cycles, but hand-merged or wire-sourced maps can.SailsProgram.resolveInServicewalks the AST directly rather than going throughthis.services(which rebuildsSailsService+ a freshTypeRegistryon every access). Safe for hot walker loops.substituteGenericsreturns the same reference for subtrees that contained no substitutions — walkers traversing large trees with sparse type parameters don't allocate new nodes unnecessarily._substituteGenericsuses immutable visited-set copies on the recursion path so a throw mid-recursion leaves no poisoned shared state.