diff --git a/js/src/sails-idl-v2.ts b/js/src/sails-idl-v2.ts index 368710ca4..25299fa3d 100644 --- a/js/src/sails-idl-v2.ts +++ b/js/src/sails-idl-v2.ts @@ -2,6 +2,7 @@ import { GearApi, HexString, UserMessageSent } from '@gear-js/api'; import { u8aToHex, u8aToU8a } from '@polkadot/util'; import type { + Type, TypeDecl, IIdlDoc, IServiceExpo, @@ -98,6 +99,46 @@ const _getArgsForTxBuilder = (args: any[], params: ISailsFuncArg[]) => { return args.slice(0, params.length); }; +/** + * Collect every `Type` in scope for a service per the self-sufficient service IDL + * contract: depth-first across the `extends` chain (with cycle detection), then the + * service's own `types` last so locals shadow base definitions on name collision. + * + * Throws on a cyclic `extends` graph (`A → B → A`), reporting the chain. + * Used by both `SailsService` (to seed its `TypeResolver`) and `SailsProgram` + * (to build `resolveInService`'s lazy index). + */ +const _collectServiceScopeTypes = ( + service: IServiceUnit, + resolveServiceUnit?: (ident: IServiceIdent) => IServiceUnit | undefined, +): Type[] => { + const out: Type[] = []; + const walk = (unit: IServiceUnit, visited: Set) => { + if (visited.has(unit.name)) { + throw new Error( + `Cyclic service-extends chain detected at "${unit.name}" — chain: ` + + `${[...visited, unit.name].join(' → ')}`, + ); + } + const nextVisited = new Set(visited); + nextVisited.add(unit.name); + + if (resolveServiceUnit && unit.extends?.length) { + for (const ident of unit.extends as IServiceIdent[]) { + const baseUnit = resolveServiceUnit(ident); + if (!baseUnit) { + throw new Error(`Service definition for "${ident.name}" not found in IDL`); + } + walk(baseUnit, nextVisited); + } + } + + for (const t of unit.types ?? []) out.push(t); + }; + walk(service, new Set()); + return out; +}; + const _assertMatchingHeader = ( payload: Uint8Array | HexString, expected: SailsMessageHeader, @@ -129,6 +170,10 @@ export class SailsProgram { private _api?: GearApi; private _programId?: HexString; private _services: Map; + // Lazy index for resolveInService: per-service `Map`, pre-merged + // with the service's transitive extends chain. Populated on first call; not + // invalidated because `_doc` is immutable after parse. + private _serviceTypeIndex?: Map>; private _resolveServiceUnit = (ident: IServiceIdent): IServiceUnit | undefined => { if (!ident.interface_id) { throw new Error(`Service "${ident.name}" is missing interface_id in IDL`); @@ -144,7 +189,7 @@ export class SailsProgram { constructor(doc: IIdlDoc) { this._doc = doc; if (this._doc.program) { - this._typeResolver = new TypeResolver(this._doc.program.types); + this._typeResolver = new TypeResolver(this._doc.program.types ?? []); } this._services = this._initServices(); } @@ -223,6 +268,44 @@ export class SailsProgram { return services; } + /** + * Resolve a `TypeDecl` to its user-type definition in the scope of a named service. + * + * Per the self-sufficient service IDL contract (see `docs/idl-v2-spec.md`), scope is + * the service's own `types` plus the types of every service it extends, transitively. + * Service-local definitions shadow base-service definitions on name collision. + * Program-level `types` are NOT in scope — they belong to program/ctor declarations. + * + * Returns `undefined` when the service doesn't exist, the `TypeDecl` isn't a named + * user type, or the name is not declared in the service's transitive scope. + * + * Backed by a lazy `Map`-based index — O(1) per call after the first invocation. + * Safe to call in tight loops while walking large IDL trees. + */ + resolveInService(serviceName: string, typeDecl: TypeDecl): Type | undefined { + if (typeof typeDecl === 'string' || typeDecl.kind !== 'named') return undefined; + if (!this._serviceTypeIndex) this._buildTypeIndex(); + return this._serviceTypeIndex!.get(serviceName)?.get(typeDecl.name); + } + + private _buildTypeIndex(): void { + const serviceIndex = new Map>(); + const unitByName = new Map(); + for (const unit of this._doc.services ?? []) unitByName.set(unit.name, unit); + + const lookupBase = (ident: IServiceIdent): IServiceUnit | undefined => unitByName.get(ident.name); + + for (const unit of this._doc.services ?? []) { + const merged = new Map(); + // Locals come last so they win on Map.set last-write-wins. + for (const t of _collectServiceScopeTypes(unit, lookupBase)) { + merged.set(t.name, t); + } + serviceIndex.set(unit.name, merged); + } + this._serviceTypeIndex = serviceIndex; + } + /** #### Constructor functions with arguments from the parsed IDL */ get ctors(): Record | null { if (!this._doc.program) { @@ -349,7 +432,8 @@ export class SailsService implements ISailsService { this._api = api; this._programId = programId; this._resolveServiceUnit = resolveServiceUnit; - this._typeResolver = new TypeResolver(service.types); + // Self-contained scope: bases first so locals shadow on collision. + this._typeResolver = new TypeResolver(_collectServiceScopeTypes(service, resolveServiceUnit)); this._routeIdx = routeIdx; this.events = this._getEvents(service); diff --git a/js/src/type-resolver-idl-v2.ts b/js/src/type-resolver-idl-v2.ts index 0a0b23162..8dbb5c897 100644 --- a/js/src/type-resolver-idl-v2.ts +++ b/js/src/type-resolver-idl-v2.ts @@ -21,18 +21,177 @@ export class TypeResolver { const scaleTypes: Record = {}; const userTypes: Record = {}; + // Iteration order is last-write-wins: when two entries share a name, the later one + // overrides the earlier. Callers that want a "shadowing" merge pass already-merged + // arrays in the desired override order (e.g. base-service types first, locals last). for (const type of types) { userTypes[type.name] = type; + } + this._userTypes = userTypes; + for (const type of Object.values(userTypes)) { if (!type.type_params?.length) { - // register non-generic by name scaleTypes[type.name] = this.getTypeDef(type); } } - this._userTypes = userTypes; this.registry.setKnownTypes({ types: scaleTypes }); this.registry.register(scaleTypes); } + /** + * Resolve a named user type to its `Type` definition. + * + * Two call shapes: + * - `resolveNamed(typeDecl)` — pass a `TypeDecl`; returns the user type for a + * `{ kind: 'named', name, generics? }` decl, or `undefined` for primitives, slices, + * arrays, tuples, type parameters, and unknown names. + * - `resolveNamed(name, generics?)` — pass the user type's name directly, with an + * optional concrete generics list. + * + * When concrete generics are provided (either via the overload or via `typeDecl.generics`), + * the returned `Type` is a **concrete** substituted copy: every `{ kind: 'generic', name }` + * leaf is replaced according to the user type's declared `type_params`, and `type_params` + * is omitted from the result. When no generics are provided, the raw user `Type` is + * returned as-is (shared with the resolver's internal state — do not mutate). + */ + resolveNamed(type: TypeDecl): Type | undefined; + resolveNamed(name: string, generics?: TypeDecl[]): Type | undefined; + resolveNamed(typeOrName: TypeDecl | string, generics?: TypeDecl[]): Type | undefined { + let name: string; + let concrete: TypeDecl[] | undefined; + + if (typeof typeOrName === 'string') { + // String is ambiguous: it's either a primitive (`'u32'`) or a user-type name. + // Primitives aren't in `_userTypes`, so lookup naturally returns `undefined` for them. + name = typeOrName; + concrete = generics; + } else if (typeOrName.kind === 'named') { + name = typeOrName.name; + concrete = generics ?? typeOrName.generics; + } else { + return undefined; + } + + const userType = this._userTypes[name]; + if (!userType) return undefined; + if (!concrete?.length) return userType; + + const substitutions = this._genericsSubstitutions(userType, concrete); + return this._resolveTypeGenerics(userType, substitutions); + } + + /** + * Recursively resolve type parameters through a `TypeDecl` tree. + * + * Pure: does not mutate inputs. Idempotent: passing an already-resolved tree yields an + * equivalent tree. Only `{ kind: 'generic', name: 'T' }` leaves whose `name` appears in + * `substitutions` are replaced; wrapper shapes (`Option`, `Vec`, `Result`, custom + * generics, and any `named` decl) are preserved and their inner types resolved in place. + * + * Recurses through replacement chains (`{ T: U, U: u32 }` resolves `T` to `u32`). + * Cyclic maps (`{ T: { kind: 'generic', name: 'T' } }` or `{ T: U, U: T }`) are detected at + * runtime and throw rather than stack-overflowing. + */ + resolveGenerics(type: TypeDecl, substitutions: Record = {}): TypeDecl { + return this._resolveGenerics(type, substitutions, new Set()); + } + + private _resolveGenerics( + type: TypeDecl, + substitutions: Record, + visited: Set, + ): TypeDecl { + if (typeof type === 'string') return type; + if (type.kind === 'slice') { + const item = this._resolveGenerics(type.item, substitutions, visited); + return item === type.item ? type : { kind: 'slice', item }; + } + if (type.kind === 'array') { + const item = this._resolveGenerics(type.item, substitutions, visited); + return item === type.item ? type : { kind: 'array', item, len: type.len }; + } + if (type.kind === 'tuple') { + const next = type.types.map((t) => this._resolveGenerics(t, substitutions, visited)); + return next.every((t, i) => t === type.types[i]) ? type : { kind: 'tuple', types: next }; + } + if (type.kind === 'generic') { + // Explicit type-parameter leaf. Track visited names so a cyclic map + // (`{ T: T }`, `{ T: U, U: T }`) throws instead of stack-overflowing. + const replacement = substitutions[type.name]; + if (replacement === undefined) return type; + if (visited.has(type.name)) { + throw new Error( + `Cyclic substitution detected while resolving type parameter "${type.name}" — ` + + `substitution chain: ${[...visited, type.name].join(' → ')}`, + ); + } + const nextVisited = new Set(visited); + nextVisited.add(type.name); + return this._resolveGenerics(replacement, substitutions, nextVisited); + } + if (type.kind === 'named') { + if (!type.generics?.length) return type; + const next = type.generics.map((g) => this._resolveGenerics(g, substitutions, visited)); + return next.every((g, i) => g === type.generics![i]) + ? type + : { kind: 'named', name: type.name, generics: next }; + } + throw new Error('Unknown TypeDecl kind :: ' + JSON.stringify(type)); + } + + // Build a substitution map by zipping a user type's declared `type_params` with a concrete + // generics list. Internal: callers should use `resolveNamed(name, generics)` instead of + // building substitution maps by hand. + private _genericsSubstitutions(userType: Type, generics: TypeDecl[] = []): Record { + const map: Record = {}; + const params = userType.type_params ?? []; + const len = Math.min(params.length, generics.length); + for (let i = 0; i < len; i++) { + map[params[i].name] = generics[i]; + } + return map; + } + + // Return a `Type` with every `generic` leaf substituted and `type_params` stripped. The result + // is a shallow copy — fields/variants/targets are recursively resolved via `resolveGenerics`, + // which preserves unchanged subtrees by reference. + private _resolveTypeGenerics(type: Type, substitutions: Record): Type { + if (type.kind === 'struct') { + return { + kind: 'struct', + name: type.name, + docs: type.docs, + annotations: type.annotations, + fields: type.fields.map((f) => ({ + ...f, + type: this.resolveGenerics(f.type, substitutions), + })), + }; + } + if (type.kind === 'enum') { + return { + kind: 'enum', + name: type.name, + docs: type.docs, + annotations: type.annotations, + variants: type.variants.map((v) => ({ + ...v, + fields: v.fields.map((f) => ({ + ...f, + type: this.resolveGenerics(f.type, substitutions), + })), + })), + }; + } + // alias + return { + kind: 'alias', + name: type.name, + docs: type.docs, + annotations: type.annotations, + target: this.resolveGenerics(type.target, substitutions), + }; + } + /** * Convert a `TypeDecl` into a concrete string name, resolving generic parameters. * @@ -131,11 +290,7 @@ export class TypeResolver { .map((t: TypeDecl) => this.getTypeDeclString(t, generics, 'canonical')) .join('')}`; if (!this.registry.hasType(canonicalName)) { - // type param to generic map, i.e, { "T": "String", "U": { "kind": "named", "name": "Option", "generics": ["u32"]} } - const generics_map: Record = {}; - for (let i = 0; i < userType.type_params.length; i++) { - generics_map[userType.type_params[i].name] = type.generics[i]; - } + const generics_map = this._genericsSubstitutions(userType, type.generics); const typeDef = this.getTypeDef(userType, generics_map); /// When a user type with generics is resolved, the resolver constructs two names: // - genericName: readable, type-like syntax (example MyType>). diff --git a/js/test/idl-v2-parser-type-resolver.test.ts b/js/test/idl-v2-parser-type-resolver.test.ts index d7bbe2718..aaf7b61ee 100644 --- a/js/test/idl-v2-parser-type-resolver.test.ts +++ b/js/test/idl-v2-parser-type-resolver.test.ts @@ -343,6 +343,189 @@ describe('type-resolver-v2 generics', () => { }); }); +describe('sails v2 service-scoped type resolution', () => { + const TWO_SERVICES_SAME_NAME = ` + !@sails: 1.0.0-beta.3 + + service A@0xa667a3b129e57f5c { + functions { + Set(p: Packet); + } + types { + struct Packet { + payload: [u8; 4], + } + } + } + + service B@0x8b02064fa4f2f602 { + functions { + Set(p: Packet); + } + types { + struct Packet { + payload: [u8; 8], + } + } + } + + program Test { + services { + A@0xa667a3b129e57f5c, + B@0x8b02064fa4f2f602, + } + } + `; + + test('resolveInService returns the service-local Type on name collision', () => { + const program = new SailsProgram(parser.parse(TWO_SERVICES_SAME_NAME)); + + const a = program.resolveInService('A', { kind: 'named', name: 'Packet' }); + const b = program.resolveInService('B', { kind: 'named', name: 'Packet' }); + expect(a?.kind).toBe('struct'); + expect(b?.kind).toBe('struct'); + // Differentiate by the array length on the single field. + const aField = (a as any).fields[0].type; + const bField = (b as any).fields[0].type; + expect(aField).toEqual({ kind: 'array', item: 'u8', len: 4 }); + expect(bField).toEqual({ kind: 'array', item: 'u8', len: 8 }); + }); + + test('resolveInService returns undefined for unknown service names', () => { + const program = new SailsProgram(parser.parse(TWO_SERVICES_SAME_NAME)); + expect(program.resolveInService('Nonexistent', { kind: 'named', name: 'Packet' })).toBeUndefined(); + }); + + test('service resolver does NOT see program-level types (self-sufficient service IDL)', () => { + // Per the self-sufficient service IDL contract (docs/idl-v2-spec.md), program.types + // are scoped to program/ctor declarations and are NOT ambient for services. A service + // IDL must be resolvable from its own `types` plus its extends chain. + const text = ` + !@sails: 1.0.0-beta.3 + + service A@0x4071744d7e684110 { + functions { + Ping() -> u32; + } + } + + program Test { + constructors { + Default(shared: Shared); + } + services { + A@0x4071744d7e684110, + } + types { + struct Shared { + v: u32, + } + } + } + `; + const program = new SailsProgram(parser.parse(text)); + expect(program.resolveInService('A', { kind: 'named', name: 'Shared' })).toBeUndefined(); + // The service's own resolver must not register the program-level type either. + expect(program.services['A'].registry.hasType('Shared')).toBe(false); + }); + + test('extended services see base service types through the extends chain', () => { + // Per the spec: a service resolves types from its own `types` plus from + // explicitly extended service interfaces. Here `Child` has no `Shared` of its own, + // but its base `Base` does — and the extension must surface it. + const text = ` + !@sails: 1.0.0-beta.3 + + service Base { + functions { + Ping() -> u32; + } + types { + struct Shared { + v: u32, + } + } + } + + service Child { + extends { + Base, + } + } + + program Test { + constructors { + Default(); + } + services { + Child, + } + } + `; + const program = new SailsProgram(parser.parse(text)); + // Direct lookup: extends-merged scope is reachable through resolveInService. + const fromProgram = program.resolveInService('Child', { kind: 'named', name: 'Shared' }); + expect(fromProgram?.kind).toBe('struct'); + expect(fromProgram?.name).toBe('Shared'); + + // The extender's own TypeResolver also has the base's type folded in. + const child = program.services['Child']; + const fromResolver = child.typeResolver.resolveNamed({ kind: 'named', name: 'Shared' }); + expect(fromResolver?.kind).toBe('struct'); + expect(fromResolver?.name).toBe('Shared'); + }); + + test('sibling services do not bleed types via SailsProgram.resolveInService', () => { + // A and B both declare `Packet` with different shapes. resolveInService must + // return each service's own definition, never the sibling's. + const program = new SailsProgram(parser.parse(TWO_SERVICES_SAME_NAME)); + const a = program.resolveInService('A', { kind: 'named', name: 'Packet' }) as any; + const b = program.resolveInService('B', { kind: 'named', name: 'Packet' }) as any; + expect(a.fields[0].type).toEqual({ kind: 'array', item: 'u8', len: 4 }); + expect(b.fields[0].type).toEqual({ kind: 'array', item: 'u8', len: 8 }); + // And neither service should see a same-named type that exists only on the other. + // (Already proven by the differing shapes above; this is a redundant invariant.) + expect(a.fields[0].type).not.toEqual(b.fields[0].type); + }); + + test('generic substitution: Envelope<[u8]>.payload resolves to [u8]', () => { + const text = ` + !@sails: 1.0.0-beta.3 + + service Gen@0x8c5db6384e4cf753 { + functions { + SetPayload(p: Envelope<[u8]>); + } + types { + struct Envelope { + id: u32, + payload: T, + } + } + } + + program Test { + services { + Gen@0x8c5db6384e4cf753, + } + } + `; + const program = new SailsProgram(parser.parse(text)); + const service = program.services['Gen']; + // resolveNamed(name, generics) returns a concrete substituted Type. + const envelope = service.typeResolver.resolveNamed({ + kind: 'named', + name: 'Envelope', + generics: [{ kind: 'slice', item: 'u8' }], + }); + expect(envelope?.kind).toBe('struct'); + // type_params are stripped from the concrete result. + expect(envelope?.type_params).toBeUndefined(); + const payloadField = (envelope as any).fields.find((f: any) => f.name === 'payload'); + expect(payloadField?.type).toEqual({ kind: 'slice', item: 'u8' }); + }); +}); + describe('v2 decodeResult header validation', () => { const idl = ` service Counter { diff --git a/js/test/idl-v2-type-resolver.test.ts b/js/test/idl-v2-type-resolver.test.ts index 1ee369301..27852c6b9 100644 --- a/js/test/idl-v2-type-resolver.test.ts +++ b/js/test/idl-v2-type-resolver.test.ts @@ -1,6 +1,7 @@ import { TypeRegistry } from '@polkadot/types/create'; -import type { Type, TypeDecl } from 'sails-js-types'; +import type { IServiceIdent, IServiceUnit, Type, TypeDecl } from 'sails-js-types'; +import { SailsService } from '../src/sails-idl-v2.js'; import { TypeResolver } from '../src/type-resolver-idl-v2.js'; const named = (name: string, generics?: TypeDecl[]): TypeDecl => ({ @@ -288,6 +289,228 @@ describe('type-resolver-v2 structs', () => { }); }); +describe('type-resolver-v2 resolveGenerics', () => { + const resolver = new TypeResolver([]); + + test('replaces a type parameter leaf with a primitive', () => { + expect(resolver.resolveGenerics(generic('T'), { T: 'u32' })).toBe('u32'); + }); + + test('recurses through slice / array / tuple', () => { + const input: TypeDecl = { + kind: 'tuple', + types: [ + { kind: 'slice', item: generic('T') }, + { kind: 'array', item: generic('T'), len: 4 }, + ], + }; + expect(resolver.resolveGenerics(input, { T: 'u8' })).toEqual({ + kind: 'tuple', + types: [ + { kind: 'slice', item: 'u8' }, + { kind: 'array', item: 'u8', len: 4 }, + ], + }); + }); + + test('recurses through named-with-generics (Option, custom wrappers)', () => { + const input: TypeDecl = named('Envelope', [named('Option', [generic('T')])]); + expect( + resolver.resolveGenerics(input, { T: { kind: 'slice', item: 'u8' } }), + ).toEqual(named('Envelope', [named('Option', [{ kind: 'slice', item: 'u8' }])])); + }); + + test('passes through named refs unchanged (named is a concrete user type, never a param)', () => { + expect(resolver.resolveGenerics(named('Unknown'), {})).toEqual(named('Unknown')); + // Even if substitutions map has a matching name, a `named` decl is not substituted. + expect(resolver.resolveGenerics(named('Unknown'), { Unknown: 'u32' })).toEqual( + named('Unknown'), + ); + }); + + test('is a no-op on primitives and on inputs with no type params', () => { + expect(resolver.resolveGenerics('u32')).toBe('u32'); + expect(resolver.resolveGenerics({ kind: 'slice', item: 'u8' }, { T: 'u64' })).toEqual({ + kind: 'slice', + item: 'u8', + }); + }); + + test('is idempotent', () => { + const input: TypeDecl = named('Envelope', [generic('T')]); + const once = resolver.resolveGenerics(input, { T: 'u32' }); + expect(resolver.resolveGenerics(once, { T: 'u32' })).toEqual(once); + }); + + test('does not mutate the input tree', () => { + const input: TypeDecl = { kind: 'slice', item: generic('T') }; + const snapshot = structuredClone(input); + resolver.resolveGenerics(input, { T: 'u8' }); + expect(input).toEqual(snapshot); + }); + + test('resolves substitution chains (T -> U -> u32)', () => { + expect(resolver.resolveGenerics(generic('T'), { T: generic('U'), U: 'u32' })).toBe('u32'); + }); + + test('throws on self-referential substitution map', () => { + expect(() => resolver.resolveGenerics(generic('T'), { T: generic('T') })).toThrow( + /[Cc]yclic/, + ); + }); + + test('throws on cyclic substitution chain (T -> U -> T)', () => { + expect(() => + resolver.resolveGenerics(generic('T'), { T: generic('U'), U: generic('T') }), + ).toThrow(/[Cc]yclic/); + }); + + test('throws on unknown TypeDecl kind', () => { + // A `Type` (kind: 'struct') is not a valid TypeDecl — catch the misuse loudly. + const bogus = { kind: 'struct', name: 'X', fields: [] } as unknown as TypeDecl; + expect(() => resolver.resolveGenerics(bogus)).toThrow(/Unknown TypeDecl kind/); + }); +}); + +describe('type-resolver-v2 resolveNamed', () => { + const packet: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 4 } }], + }; + const envelope: Type = { + kind: 'struct', + name: 'Envelope', + type_params: [{ name: 'T' }], + fields: [ + { name: 'id', type: 'u32' }, + { name: 'payload', type: generic('T') }, + ], + }; + const maybe: Type = { + kind: 'enum', + name: 'Maybe', + type_params: [{ name: 'T' }], + variants: [ + { name: 'None', fields: [] }, + { name: 'Some', fields: [{ type: generic('T') }] }, + ], + }; + const genericAlias: Type = { + kind: 'alias', + name: 'MaybeOpt', + type_params: [{ name: 'T' }], + target: named('Option', [generic('T')]), + }; + + test('returns the raw user Type for a known named decl with no generics', () => { + const resolver = new TypeResolver([packet]); + expect(resolver.resolveNamed(named('Packet'))).toBe(packet); + }); + + test('returns a concrete substituted Type when the named decl carries generics', () => { + const resolver = new TypeResolver([envelope]); + const result = resolver.resolveNamed(named('Envelope', ['u32'])); + expect(result).toEqual({ + kind: 'struct', + name: 'Envelope', + docs: undefined, + annotations: undefined, + fields: [ + { name: 'id', type: 'u32' }, + { name: 'payload', type: 'u32' }, + ], + }); + // type_params must be omitted on the concrete result. + expect(result?.type_params).toBeUndefined(); + }); + + test('substitutes generics across enum variants', () => { + const resolver = new TypeResolver([maybe]); + const result = resolver.resolveNamed(named('Maybe', [{ kind: 'slice', item: 'u8' }])); + expect(result?.kind).toBe('enum'); + const variants = (result as any).variants; + expect(variants[0]).toEqual({ name: 'None', fields: [] }); + expect(variants[1].fields[0].type).toEqual({ kind: 'slice', item: 'u8' }); + expect(result?.type_params).toBeUndefined(); + }); + + test('substitutes generics through alias targets', () => { + const resolver = new TypeResolver([genericAlias]); + const result = resolver.resolveNamed(named('MaybeOpt', ['u32'])); + expect(result).toEqual({ + kind: 'alias', + name: 'MaybeOpt', + docs: undefined, + annotations: undefined, + target: named('Option', ['u32']), + }); + }); + + test('string overload: by name returns the raw user Type', () => { + const resolver = new TypeResolver([packet]); + expect(resolver.resolveNamed('Packet')).toBe(packet); + }); + + test('string overload: name + concrete generics returns substituted Type', () => { + const resolver = new TypeResolver([envelope]); + const result = resolver.resolveNamed('Envelope', [{ kind: 'slice', item: 'u8' }]); + expect((result as any).fields[1].type).toEqual({ kind: 'slice', item: 'u8' }); + expect(result?.type_params).toBeUndefined(); + }); + + test('string overload prefers the explicit generics list over any on the decl', () => { + const resolver = new TypeResolver([envelope]); + // Caller has a name string, provides its own generics. + const result = resolver.resolveNamed('Envelope', ['u64']); + expect((result as any).fields[1].type).toBe('u64'); + }); + + test('returns undefined for primitives, slices, arrays, tuples, type params', () => { + const resolver = new TypeResolver([]); + expect(resolver.resolveNamed('u32')).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'slice', item: 'u8' })).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'array', item: 'u8', len: 4 })).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'tuple', types: ['u8', 'u16'] })).toBeUndefined(); + expect(resolver.resolveNamed(generic('T'))).toBeUndefined(); + }); + + test('returns undefined for unknown names', () => { + const resolver = new TypeResolver([]); + expect(resolver.resolveNamed(named('Unknown'))).toBeUndefined(); + expect(resolver.resolveNamed('Unknown')).toBeUndefined(); + expect(resolver.resolveNamed('Unknown', ['u32'])).toBeUndefined(); + }); +}); + +describe('type-resolver-v2 last-write-wins merge', () => { + // The constructor takes a single pre-merged Type[]; later entries override earlier + // ones on name collision. Callers compose the input array in the desired override + // order (e.g. base-service types first, service-local last for IDL extends-merging). + const firstPacket: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 4 } }], + }; + const secondPacket: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 8 } }], + }; + + test('last-declared type wins on name collision', () => { + const resolver = new TypeResolver([firstPacket, secondPacket]); + expect(resolver.resolveNamed(named('Packet'))).toBe(secondPacket); + }); + + test('registry reflects the last-declared shape', () => { + const resolver = new TypeResolver([firstPacket, secondPacket]); + const encoded = resolver.registry.createType('Packet', { payload: [1, 2, 3, 4, 5, 6, 7, 8] }); + expect(encoded.toJSON()).toEqual({ payload: '0x0102030405060708' }); + expect(() => resolver.registry.createType('Packet', { payload: [1, 2, 3, 4] })).toThrow(); + }); +}); + describe('type-resolver-v2 aliases', () => { test('simple alias', () => { const userType: any = { @@ -344,3 +567,65 @@ describe('type-resolver-v2 aliases', () => { expect(encodedNull.toJSON()).toBe(null); }); }); + +// Minimal mock service-units for direct-construction tests. The SailsService +// constructor reads `interface_id` via `InterfaceId.from(...)` in `_getEvents` +// regardless of whether events exist, so we pass an 8-byte zero placeholder. +const ZERO_IFACE = new Uint8Array(8); + +const mockIdent = (name: string): IServiceIdent => ({ name, interface_id: ZERO_IFACE }); + +const mockUnit = (name: string, extendsList: IServiceIdent[]): IServiceUnit => ({ + name, + interface_id: ZERO_IFACE, + extends: extendsList, + funcs: [], + events: [], +}); + +const lookupFromMap = + (units: Record) => + (i: IServiceIdent): IServiceUnit | undefined => + units[i.name]; + +describe('SailsService extends-chain cycle detection', () => { + // _collectServiceScopeTypes (module-private) walks the extends graph depth-first + // with a visited-set guard. Pathological IDLs with cyclic extends graphs + // (`A extends B`, `B extends A`) must throw with the chain in the message rather + // than stack-overflow. The parser would normally reject these, but hand-built or + // wire-sourced ASTs can still produce them — the runtime guard is the safety net. + + test('throws on self-cycle (A extends A)', () => { + const a = mockUnit('A', [mockIdent('A')]); + const lookup = lookupFromMap({ A: a }); + expect(() => new SailsService(a, undefined, undefined, 0, lookup)).toThrow( + /Cyclic service-extends chain detected at "A".*A → A/s, + ); + }); + + test('throws on mutual cycle (A extends B, B extends A)', () => { + const a = mockUnit('A', [mockIdent('B')]); + const b = mockUnit('B', [mockIdent('A')]); + const lookup = lookupFromMap({ A: a, B: b }); + // Walk starts from A → visits B → tries to revisit A. Chain is reported. + expect(() => new SailsService(a, undefined, undefined, 0, lookup)).toThrow( + /Cyclic service-extends chain detected at "A".*A → B → A/s, + ); + }); + + test('does not throw when the same service appears as a sibling base twice (diamond)', () => { + // A extends [B, C]; B extends C; C is a leaf. Not a cycle — `C` is reachable + // from A through two paths, but the visited-set is per-recursion, not global. + const c: IServiceUnit = { + name: 'C', + interface_id: ZERO_IFACE, + funcs: [], + events: [], + types: [{ kind: 'struct', name: 'Leaf', fields: [{ name: 'v', type: 'u32' }] }], + }; + const b: IServiceUnit = { ...mockUnit('B', [mockIdent('C')]) }; + const a: IServiceUnit = { ...mockUnit('A', [mockIdent('B'), mockIdent('C')]) }; + const lookup = lookupFromMap({ A: a, B: b, C: c }); + expect(() => new SailsService(a, undefined, undefined, 0, lookup)).not.toThrow(); + }); +});