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>
Design rationale: self-sufficient service IDLThe The ruleA service IDL is self-sufficient and independently distributable. A Cross-scope type references are restricted to:
The parser pushes program types into the root scope before validating each service (
|
vobradovich
left a comment
There was a problem hiding this comment.
violates self-sufficient service IDL concept
Aligns with the self-sufficient service IDL contract being formalized in #1341: a service IDL is independently distributable and resolves its function/event/throws payload types from its own `types` block plus the types of every service it explicitly extends, transitively. Program-level `types` are scoped to program/ctor declarations and are no longer threaded into per-service resolvers. - SailsService: remove `ambientTypes` constructor param + `_ambientTypes` field. Resolver is now seeded by `_collectServiceScopeTypes`, a module- level helper that walks the `extends` chain depth-first with cycle detection (mirrors `_substituteGenerics`'s visited-set pattern). - SailsProgram: stop passing `program.types` into services. Rewrite `resolveInService` to return only hits from the merged service-local + extends-chain scope. Drop `_programTypeIndex`; the lazy index is now per-service `Map<typeName, Type>` with extends pre-merged. - Tests: flip the two ambient-themed assertions (now require `resolveInService(...)` and `registry.hasType(...)` to fail for a program-only type, matching #1341's parallel JS tests). Rewrite the extends-chain test to assert base-service types instead. Add a sibling- bleed test. Reframe the call-site shadowing block as a generic last-write-wins test. - TypeResolver constructor JSDoc: drop the ambient/local framing in favour of a generic last-write-wins description. Closes #1317. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add SailsService extends-chain cycle-guard tests: self-cycle (`A → A`) and mutual cycle (`A → B → A`) throw with the chain reported; diamond inheritance (A → [B, C], B → C) does not throw. - Trim two over-explained inline comments in `_collectServiceScopeTypes` call sites flagged by /simplify quality review. - Make the extends-chain test fixture parser-agnostic: Base now exposes `Ping() -> u32` instead of `GetShared() -> Shared`. Keeps Base.types populated as the merge target without referencing extender-resolved types, so the IDL parses cleanly under both current master and the tightened validator in #1341. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI lint flagged 4 errors from unicorn: - consistent-function-scoping: ident/unit helpers were inside the describe block; lifted them to module scope as mockIdent/mockUnit. - no-nested-ternary: replaced the inline `i.name === 'A' ? a : ...` ternary chains in the lookup closures with a tiny `lookupFromMap` helper that takes a `Record<string, IServiceUnit>`. Each test now builds its name → unit map explicitly. Tests are otherwise unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests broke after the simplify-pass cleanups landed: 1. Cycle-guard "diamond" test — `SailsService.constructor` calls `_getEvents` which unconditionally invokes `InterfaceId.from(service.interface_id)` at line 585 (before iterating events). The mock units left `interface_id` undefined, so `InterfaceId.fromBytes(undefined)` threw TypeError. Fixed by giving mocks an 8-byte zero placeholder (`ZERO_IFACE`) and explicit empty `funcs`/`events` arrays. Self-cycle and mutual-cycle tests were unaffected because `_collectServiceScopeTypes` throws inside the resolver init, before `_getEvents` runs. 2. Extends-chain integration test — fixture pinned `Base@0xb45ddc41cf66e2da` but the parser computes interface_ids from interface contents (per `docs/interface-id-spec.md`), and the prior fixture rewrite (`GetShared() -> Shared` → `Ping() -> u32`) changed the content hash. Following the convention used by #1341's added tests, drop explicit `@0x...` annotations and let the parser compute them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # js/test/idl-v2-parser-type-resolver.test.ts
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
Per-service
TypeResolverinstances seeded bySailsProgram.servicessee only the service's owntypesplus the transitiveextendschain. The chain walk is depth-first with cycle detection; on a pathologicalA extends B, B extends Agraph the constructor throws with the chain in the message. Service-local types shadow base-service types on name collision.getTypeDeclStringwas also updated to call the renamedresolveGenericshelper 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:'generic', 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
24+ tests across the resolver + parser-resolver suites.
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 (raw and concrete-generics paths), returnsundefinedfor primitives/slices/arrays/tuples/unknown names/bare type_params.resolveGenerics: zip, no type_params, extra generics truncated.A extends A) and mutual cycle (A extends B, B extends A) throw with the chain reported; diamond inheritance does not throw.Integration (
js/test/idl-v2-parser-type-resolver.test.ts):Packetwith different shapes →resolveInService('A', ...)vs'B'return their own definitions.undefined.resolveInServicereturnsundefinedandservice.registry.hasType('Shared')isfalse. Mirrors the JS test added in chore: Clarify service-local IDL type scopes #1341.typesthrough the transitive merge, both viaprogram.resolveInService('Child', ...)and viachild.typeResolver.resolveNamed(...).Packettypes resolved throughSailsProgram.resolveInServicereturn distinct shapes — no cross-service bleed.Envelope<[u8]>→ walker feeds struct fields throughsubstituteGenerics+resolveGenericsto 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 byresolveGenericsfrom 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), and is backed by a lazyMap<serviceName, Map<typeName, Type>>that pre-merges each service's transitive extends scope. O(1) per call after the first invocation.substituteGenericsreturns the same reference for subtrees that contained no substitutions — walkers traversing large trees with sparse type parameters don't allocate new nodes unnecessarily._collectServiceScopeTypesuses the same visited-set + immutable-copy cycle-guard pattern as_substituteGenerics, so a throw mid-recursion leaves no poisoned shared state.