/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ts from "typescript"; import * as path from "path"; import {Type} from "../java"; import FUNCTION_TYPE_NAME = Type.FUNCTION_TYPE_NAME; export class JavaScriptTypeMapping { // Primary cache: Use type signatures (preferring type.id) as cache keys // TypeScript assigns stable IDs to all types, so we don't need secondary caches private readonly typeCache: Map = new Map(); private readonly methodCache: Map = new Map(); private readonly regExpSymbol: ts.Symbol | undefined; private readonly stringWrapperType: ts.Type | undefined; private readonly numberWrapperType: ts.Type | undefined; private readonly booleanWrapperType: ts.Type | undefined; constructor( private readonly checker: ts.TypeChecker, private readonly sourceRoot?: string ) { this.regExpSymbol = checker.resolveName( "RegExp", undefined, ts.SymbolFlags.Type, false ); // Resolve global wrapper types for primitives from TypeScript's lib const stringSymbol = checker.resolveName("String", undefined, ts.SymbolFlags.Type, false); const numberSymbol = checker.resolveName("Number", undefined, ts.SymbolFlags.Type, false); const booleanSymbol = checker.resolveName("Boolean", undefined, ts.SymbolFlags.Type, false); // Store the TypeScript types; conversion to Type happens on-demand if (stringSymbol) { this.stringWrapperType = checker.getDeclaredTypeOfSymbol(stringSymbol); } if (numberSymbol) { this.numberWrapperType = checker.getDeclaredTypeOfSymbol(numberSymbol); } if (booleanSymbol) { this.booleanWrapperType = checker.getDeclaredTypeOfSymbol(booleanSymbol); } } type(node: ts.Node): Type | undefined { // For identifiers, check if this references a variable // This enables fieldType attribution for variable references if (ts.isIdentifier(node)) { const variableType = this.variableType(node); if (variableType) { return variableType; } // Fall through to regular type checking if not a variable } // TypeNode needs getTypeFromTypeNode (resolves the annotation itself). // Everything else — expressions, declarations, specifiers, bindings, etc. — // uses getTypeAtLocation which works for virtually all node kinds. let type: ts.Type | undefined; if (ts.isTypeNode(node)) { type = this.checker.getTypeFromTypeNode(node); } else { type = this.checker.getTypeAtLocation(node); } return type && this.getType(type); } /** * Resolve the type of a decorator from the symbol it references, naming it by the decorator's * fully qualified name (e.g. `typeorm.Entity`) rather than the generic function type of the * decorator factory. Decorators are the JavaScript/TypeScript analogue of Java annotations, so * — like {@code J.Annotation} in the Java LST — they are typed as a {@link Type.FullyQualified} * with {@code classKind = Annotation}, which lets recipes match them by fully qualified name. * * @param node the identifier or qualified name naming the decorator (e.g. `Entity` in `@Entity()`) */ annotationType(node: ts.Node): Type.FullyQualified | undefined { const symbol = this.checker.getSymbolAtLocation(node); if (!symbol) { return undefined; } const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(symbol); if (!fullyQualifiedName || fullyQualifiedName === 'unknown') { return undefined; } return { kind: Type.Kind.Class, flags: 0, classKind: Type.Class.Kind.Annotation, fullyQualifiedName, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; } /** * Resolve the declared type of a class / interface / enum / class-expression. * * Using {@code getTypeAtLocation} on these declaration nodes is unsafe: TypeScript returns * the type of the declared *value*, not the type itself. For string or numeric enums that * ends up being the union of enum literals, which on the JS side resolves to * {@link Type.Primitive} (e.g. String for string enums). The Java-side RPC receiver then * rejects it with "A class can only be type attributed with a fully qualified type name", * because {@code J.ClassDeclaration.type} must be {@link Type.FullyQualified}. * * Instead, we resolve through the declaration's own symbol. For classes and interfaces we * reuse the full type mapping pipeline (so members, methods, supertypes and type parameters * are populated). For enums — which have no class-shaped representation in TypeScript — and * any residual non-FQ result, we build a minimal class shell whose FQN and {@code classKind} * are derived from the declaration itself. */ declarationType(node: ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration | ts.EnumDeclaration): Type.FullyQualified | undefined { const symbol = (node as { symbol?: ts.Symbol }).symbol ?? (node.name ? this.checker.getSymbolAtLocation(node.name) : undefined); if (!symbol) { return undefined; } // For classes and interfaces `getDeclaredTypeOfSymbol` yields a class-shaped ts.Type whose // symbol we can feed through the normal pipeline. For enums, however, TypeScript returns the // union of enum literals — which on the JS side resolves to `Type.Primitive` (e.g. String for // string enums) and therefore is NOT `FullyQualified`. The Java side then rejects it with // "A class can only be type attributed with a fully qualified type name". // // Since JavaScript/TypeScript has no separate enum runtime representation, we build the // class-shaped type directly from the declaration's own symbol. The `classKind` is derived // from the declaration node kind, not the (lossy) resolved type. let classKind: Type.Class.Kind; if (ts.isEnumDeclaration(node)) { classKind = Type.Class.Kind.Enum; } else if (ts.isInterfaceDeclaration(node)) { classKind = Type.Class.Kind.Interface; } else { classKind = Type.Class.Kind.Class; } const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(symbol); const cacheKey = `decl:${classKind}:${fullyQualifiedName}`; const cached = this.typeCache.get(cacheKey); if (cached && Type.isFullyQualified(cached)) { return cached as Type.FullyQualified; } // For classes and interfaces reuse the existing type-based path so that members, methods, // supertypes and type parameters are populated. Enums (and any fallback that produced a // non-FQ result) get a minimal class shell with the correct FQN + kind. if (!ts.isEnumDeclaration(node)) { const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol); if (declaredType) { const mapped = this.getType(declaredType); if (Type.isFullyQualified(mapped)) { this.typeCache.set(cacheKey, mapped); return mapped; } } } const classType: Type.Class = { kind: Type.Kind.Class, flags: 0, classKind, fullyQualifiedName, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; this.typeCache.set(cacheKey, classType); return classType; } /** * Map an exported symbol (type alias, namespace, function, ...) to its {@link Type} via the * symbol's declared or value type. Enumerator-only entry point ({@code exportedTypes}) for * exports that are not class/interface/enum declarations; those go through {@link declarationType}. */ exportedType(symbol: ts.Symbol): Type | undefined { const target = symbol.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(symbol) : symbol; let type: ts.Type | undefined; if (target.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.ValueModule)) { type = this.checker.getDeclaredTypeOfSymbol(target); } else if (target.valueDeclaration) { type = this.checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration); } return type ? this.getType(type) : undefined; } private getType(type: ts.Type): Type { // Check for error types first - these indicate type-checking failures // and should not be processed further if (type.flags & ts.TypeFlags.Any) { const intrinsicName = (type as any).intrinsicName; if (intrinsicName === 'error') { return Type.unknownType; } } // A type alias to an intersection or a plain anonymous object (e.g. kafkajs's // `export type Producer = Sender & {...}`, or `export type Consumer = {...}`) loses its name // when TypeScript resolves the structure, leaving an unnamed intersection / ``. // TypeScript still records the originating alias via `aliasSymbol`; use it to attribute the // nominal name (e.g. `kafkajs.Producer`) so the type is matchable. Unions keep their // structural Union mapping (recipes rely on it), and mapped / instantiated utility types // (`Partial`, `Record`, ...) are intentionally left untouched. if (type.aliasSymbol) { let nameableAlias = !!(type.flags & ts.TypeFlags.Intersection); if (!nameableAlias && (type.flags & ts.TypeFlags.Object)) { const objectFlags = (type as ts.ObjectType).objectFlags; nameableAlias = !!(objectFlags & ts.ObjectFlags.Anonymous) && !(objectFlags & ts.ObjectFlags.Mapped) && !(objectFlags & ts.ObjectFlags.Instantiated); } if (nameableAlias) { const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(type.aliasSymbol); if (fullyQualifiedName && fullyQualifiedName !== 'unknown') { const aliasSignature = this.getSignature(type); const cached = this.typeCache.get(aliasSignature); if (cached) { return cached; } const aliasType: Type.Class = { kind: Type.Kind.Class, flags: 0, classKind: Type.Class.Kind.Interface, fullyQualifiedName, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; this.typeCache.set(aliasSignature, aliasType); return aliasType; } } } // Skip problematic type constructs EARLY - before any caching or recursion // These can cause deep recursion and are often computed/structural types if (type.flags & ts.TypeFlags.Object) { const objectFlags = (type as ts.ObjectType).objectFlags; // Always skip mapped types (e.g., { [K in keyof T]: ... }) // These are structural/computed types that can cause infinite recursion if (objectFlags & ts.ObjectFlags.Mapped) { return Type.unknownType; } // Skip instantiated types ONLY if they're not type references // Type references like Array, Promise are instantiated but should be mapped // Other instantiated types (like object literals) should return unknown if (objectFlags & ts.ObjectFlags.Instantiated) { const isTypeReference = objectFlags & ts.ObjectFlags.Reference; if (!isTypeReference) { return Type.unknownType; } } } // Always skip conditional types (T extends U ? X : Y) if (type.flags & ts.TypeFlags.Conditional) { return Type.unknownType; } // Check cache using signature (type.id when available) const signature = this.getSignature(type); const existing = this.typeCache.get(signature); if (existing) { return existing; } // TypeScript represents `boolean` as a union of `false | true`, but the union // type still has the Boolean flag set. Check this early to return Primitive.Boolean // before we process it as a generic union type. if (type.flags & ts.TypeFlags.Boolean) { this.typeCache.set(signature, Type.Primitive.Boolean); return Type.Primitive.Boolean; } // Get symbol for later use in type detection const symbol = type.getSymbol?.(); // IMPORTANT: Check if this is a type reference to a parameterized type FIRST if (type.flags & ts.TypeFlags.Object) { const objectType = type as ts.ObjectType; const isTypeReference = objectType.objectFlags & ts.ObjectFlags.Reference; if (isTypeReference) { const typeRef = type as ts.TypeReference; const hasTypeArgs = typeRef.typeArguments && typeRef.typeArguments.length > 0; if (hasTypeArgs) { // This is a parameterized type reference (e.g., RefObject) // Extract the base class type and type arguments to create a Parameterized type // IMPORTANT: Check if the type arguments are actually type parameters (unsubstituted) // This happens when TypeScript expands type aliases but doesn't substitute the type parameters // For example, React.Ref expands to RefObject | RefCallback | null // instead of RefObject | RefCallback | null const hasUnsubstitutedTypeParams = typeRef.typeArguments!.some((arg: any) => (arg as ts.Type).flags & ts.TypeFlags.TypeParameter ); if (!hasUnsubstitutedTypeParams) { // Only create parameterized type if type arguments are actual types, not type parameters if (symbol && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias))) { const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol); // Get or create the base class type (this gets cached) const declaredSig = this.getSignature(declaredType); let classType = this.typeCache.get(declaredSig) as Type.Class | undefined; if (!classType) { classType = this.createEmptyClassType(declaredType); this.typeCache.set(declaredSig, classType); this.populateClassType(classType, declaredType); } // Shell-cache: Create parameterized type wrapper with empty typeParameters // BEFORE resolving type arguments, to prevent infinite recursion // when type argument resolution cycles back to this parameterized type const parameterized = { kind: Type.Kind.Parameterized, type: classType, typeParameters: [], fullyQualifiedName: classType.fullyQualifiedName, toJSON: function () { return Type.signature(this); } } as Type.Parameterized; this.typeCache.set(signature, parameterized); // Resolve type arguments (may recursively reference this parameterized type) const typeParameters: Type[] = []; for (const typeArg of typeRef.typeArguments!) { const resolvedArg = this.getType(typeArg as ts.Type); typeParameters.push(resolvedArg); } // Update the shell with resolved type parameters (parameterized as any).typeParameters = typeParameters; return parameterized; } } } } } // Check for class/interface/enum types (including arrays) // Arrays in JavaScript are objects with methods, so we treat them as class types if (symbol) { // Check for function symbols if (symbol.flags & ts.SymbolFlags.Function) { const callSignatures = type.getCallSignatures(); if (callSignatures.length > 0) { // Shell-cache: Create stub, cache it, then populate (prevents cycles) const functionType = this.createEmptyFunctionType(); this.typeCache.set(signature, functionType); this.populateFunctionType(functionType, callSignatures[0]); return functionType; } } // Check for function-scoped or block-scoped variables that might be functions if (symbol.flags & (ts.SymbolFlags.FunctionScopedVariable | ts.SymbolFlags.BlockScopedVariable)) { const callSignatures = type.getCallSignatures(); if (callSignatures.length > 0) { // Shell-cache: Create stub, cache it, then populate (prevents cycles) const functionType = this.createEmptyFunctionType(); this.typeCache.set(signature, functionType); this.populateFunctionType(functionType, callSignatures[0]); return functionType; } } if (symbol.flags & ts.SymbolFlags.ValueModule) { const classType = this.createEmptyClassType(type); this.typeCache.set(signature, classType); this.populateClassType(classType, type); return classType; } if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.ValueModule)) { // Get the declared type from the symbol (analogous to symType = sym.type in Java) // This is the base class type without specific type arguments const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol); const declaredSig = this.getSignature(declaredType); let classType = this.typeCache.get(declaredSig) as Type.Class | undefined; if (!classType) { // Shell-cache: Create stub, cache it, then populate (prevents cycles) classType = this.createEmptyClassType(declaredType); this.typeCache.set(declaredSig, classType); this.populateClassType(classType, declaredType); } // Return the base class type (parameterized types are handled at the beginning of getType) return classType; } if (symbol.flags & ts.SymbolFlags.TypeParameter) { return this.createGenericTypeVariable(type as ts.TypeParameter, signature); } } // Note on type aliases with type arguments (e.g., React.Ref): // TypeScript automatically substitutes type parameters when resolving type aliases. // For example, React.Ref = RefCallback | RefObject | null // When we encounter Ref, TypeScript gives us: // RefCallback | RefObject | null // The type parameters are already correctly substituted in the constituent types, // so we can just process the resolved type normally (falling through to union handling below). // Check for union types (e.g., string | number) if (type.flags & ts.TypeFlags.Union) { return this.createUnionType(type as ts.UnionType, signature); } // Check for intersection types (e.g., A & B) if (type.flags & ts.TypeFlags.Intersection) { const intersectionType = type as ts.IntersectionType; return this.createIntersectionType(intersectionType, signature); } // Check for function types without symbols (anonymous functions, function types) const callSignatures = type.getCallSignatures(); if (callSignatures && callSignatures.length > 0) { // Shell-cache: Create stub, cache it, then populate (prevents cycles) const functionType = this.createEmptyFunctionType(); this.typeCache.set(signature, functionType); this.populateFunctionType(functionType, callSignatures[0]); return functionType; } // For anonymous object types that could have circular references if (type.flags & ts.TypeFlags.Object) { const objectFlags = (type as ts.ObjectType).objectFlags; if (objectFlags & ts.ObjectFlags.Anonymous) { return Type.unknownType; } } // Pre-cache as unknownType before resolving type aliases to prevent infinite // recursion when alias resolution cycles back through getType with different type.ids this.typeCache.set(signature, Type.unknownType); const result = this.createPrimitiveOrUnknownType(type); this.typeCache.set(signature, result); return result; } private getSignature(type: ts.Type): string | number { // Try to use TypeScript's internal id if available // TypeScript assigns stable IDs to all types, including parameterized, union, and intersection types if ("id" in type && type.id !== undefined) { return type.id as number; } // Fallback: Generate a string signature based on type characteristics const typeString = this.checker.typeToString(type); const symbol = type.getSymbol?.(); if (symbol) { const declaration = symbol.valueDeclaration || symbol.declarations?.[0]; if (declaration) { const sourceFile = declaration.getSourceFile(); const fileName = sourceFile.fileName; const pos = declaration.pos; // Create unique signature from file + position + type string // This ensures types from different modules are distinguished return `${fileName}:${pos}:${typeString}`; } } // Last resort: use type string with a prefix to distinguish from numeric IDs // This might happen for synthetic types or types without declarations return `synthetic:${typeString}`; } primitiveType(node: ts.Node): Type.Primitive { const type = this.type(node); if (Type.isClass(type) && type.fullyQualifiedName === 'RegExp') { return Type.Primitive.String; } return Type.isPrimitive(type) ? type : Type.Primitive.None; } variableType(node: ts.Node): Type.Variable | undefined { let symbol: ts.Symbol | undefined; let location: ts.Node = node; // Get the symbol depending on node type if (ts.isVariableDeclaration(node)) { symbol = this.checker.getSymbolAtLocation(node.name); } else if (ts.isParameter(node)) { symbol = this.checker.getSymbolAtLocation(node.name); } else if (ts.isIdentifier(node)) { // For identifier references (like 'vi' in 'vi.fn()') symbol = this.checker.getSymbolAtLocation(node); } else if (ts.isPropertyDeclaration(node) || ts.isPropertySignature(node)) { symbol = this.checker.getSymbolAtLocation(node.name); } else { // Not a variable/parameter/property we can handle return undefined; } if (!symbol) { return undefined; } // Get the variable declaration (resolve aliases if needed) let actualSymbol = symbol; if (symbol.flags & ts.SymbolFlags.Alias) { actualSymbol = this.checker.getAliasedSymbol(symbol); } // Check if this symbol represents a variable, parameter, or property // Exclude functions, classes, interfaces, namespaces, type aliases const isExcluded = actualSymbol.flags & ( ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.TypeParameter ); if (isExcluded) { // Not a variable - it's a type, function, class, namespace, etc. return undefined; } const isVariable = actualSymbol.flags & ( ts.SymbolFlags.Variable | ts.SymbolFlags.Property | ts.SymbolFlags.FunctionScopedVariable | ts.SymbolFlags.BlockScopedVariable ); if (!isVariable) { // Not a variable we recognize return undefined; } // Get the type of the variable const variableType = this.checker.getTypeOfSymbolAtLocation(actualSymbol, location); const mappedType = this.getType(variableType); // Get the owner (declaring type) for the variable let ownerType: Type | undefined; // Check if the variable is imported if (symbol.flags & ts.SymbolFlags.Alias) { // For imported variables, find the module specifier const declarations = symbol.declarations; if (declarations && declarations.length > 0) { let importNode: ts.Node | undefined = declarations[0]; // Traverse up to find the ImportDeclaration while (importNode && !ts.isImportDeclaration(importNode)) { importNode = importNode.parent; } if (importNode && ts.isImportDeclaration(importNode)) { const importDecl = importNode as ts.ImportDeclaration; if (ts.isStringLiteral(importDecl.moduleSpecifier)) { const moduleSpecifier = importDecl.moduleSpecifier.text; // Create a Type.Class representing the module ownerType = { kind: Type.Kind.Class, flags: 0, classKind: Type.Class.Kind.Interface, fullyQualifiedName: moduleSpecifier, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; } } } } else { // For non-imported variables, check if they belong to a class/interface/namespace const parentSymbol = (actualSymbol as any).parent as ts.Symbol | undefined; if (parentSymbol) { const parentType = this.checker.getDeclaredTypeOfSymbol(parentSymbol); if (parentType) { ownerType = this.getType(parentType); // If the parent is a namespace, try to find the module it came from // This handles cases like React.forwardRef where the namespace is React // but the module is "react" if (parentSymbol.flags & ts.SymbolFlags.ValueModule || parentSymbol.flags & ts.SymbolFlags.NamespaceModule) { // Check if this namespace was imported const parentDeclarations = parentSymbol.declarations; if (parentDeclarations && parentDeclarations.length > 0) { const firstDecl = parentDeclarations[0]; const sourceFile = firstDecl.getSourceFile(); // If it's from node_modules or a .d.ts file, try to extract the module name if (sourceFile.isDeclarationFile) { const fileName = sourceFile.fileName; const moduleName = this.extractModuleNameFromPath(fileName); if (moduleName) { // Store the module as the owningClass for now // (This is a bit of a hack, but works with the current type system) if (Type.isClass(ownerType)) { (ownerType as any).owningClass = { kind: Type.Kind.Class, flags: 0, classKind: Type.Class.Kind.Interface, fullyQualifiedName: moduleName, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; } } } } } } } } // Create the Type.Variable const variable = { kind: Type.Kind.Variable, name: actualSymbol.getName(), owner: ownerType, type: mappedType, annotations: [], toJSON: function () { return Type.signature(this); } } as Type.Variable; return variable; } /** * Extract the npm module name from a file path. * Handles various package manager layouts: * - Standard: /path/node_modules/react/index.d.ts -> react * - Scoped: /path/node_modules/@types/react/index.d.ts -> react * - Scoped with __ encoding: /path/node_modules/@types/testing-library__react/index.d.ts -> @testing-library/react * - Nested node_modules: /path/node_modules/pkg/node_modules/dep/index.d.ts -> dep * - pnpm: /path/node_modules/.pnpm/react@18.2.0/node_modules/react/index.d.ts -> react * * @returns The module name, or undefined if not from node_modules */ private extractModuleNameFromPath(fileName: string): string | undefined { if (!fileName.includes('node_modules/')) { return undefined; } // Find the last occurrence of node_modules/ to handle nested dependencies // This also correctly handles pnpm's .pnpm structure const lastNodeModulesIndex = fileName.lastIndexOf('node_modules/'); const afterNodeModules = fileName.substring(lastNodeModulesIndex + 'node_modules/'.length); // Split by '/' to get path segments const segments = afterNodeModules.split('/'); if (segments.length === 0) { return undefined; } let moduleName: string; // Handle scoped packages (@scope/package) if (segments[0].startsWith('@') && segments.length > 1) { moduleName = `${segments[0]}/${segments[1]}`; } else { moduleName = segments[0]; } // Skip pnpm's .pnpm directory - it contains versioned package paths // In pnpm, the actual package is in: .pnpm/pkg@version/node_modules/pkg // So we already handled this by using lastIndexOf above if (moduleName === '.pnpm') { return undefined; } return this.normalizePackageName(moduleName); } /** * Normalize a node_modules package name to the specifier consumers actually import. * * DefinitelyTyped packages (`@types/`) are never importable under that name — the * importable specifier is ``, with DefinitelyTyped's `__` scoped-package encoding * decoded back to a `@scope/name` form. Using the importable specifier keeps attributed * fully qualified names consistent regardless of whether a type is reached through a direct * import (which already resolves via the module specifier) or transitively (e.g. a call's * return type), which falls back to the declaration file's `node_modules` path. * * Examples: * - `@types/express-serve-static-core` -> `express-serve-static-core` * - `@types/node` -> `node` * - `@types/testing-library__react` -> `@testing-library/react` */ private normalizePackageName(packageName: string): string { if (packageName.startsWith('@types/')) { packageName = packageName.substring('@types/'.length); // Decode __ encoding for scoped packages: testing-library__react -> @testing-library/react if (packageName.includes('__')) { const parts = packageName.split('__'); if (parts.length === 2) { packageName = `@${parts[0]}/${parts[1]}`; } } } return packageName; } /** * Helper to create a Type.Method object from common parameters */ private createMethodType( signature: ts.Signature, node: ts.MethodSignature | ts.FunctionDeclaration | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.FunctionExpression | ts.CallExpression | ts.NewExpression, declaringType: Type.FullyQualified, name: string, declaredFormalTypeNames: string[] = [] ): Type.Method { // Create composite cache key: declaring type + method name + signature // This prevents cache collisions when methods with identical signatures have different names // (e.g., util.isString(): any vs util.isArray(): any) const declaringTypeSig = Type.signature(declaringType); const signatureStr = this.checker.signatureToString(signature); const cacheKey = `${declaringTypeSig}#${name}${signatureStr}`; const cached = this.methodCache.get(cacheKey); if (cached) { return cached; } const returnType = signature.getReturnType(); const parameters = signature.getParameters(); const parameterTypes: Type[] = []; const parameterNames: string[] = []; for (const param of parameters) { parameterNames.push(param.getName()); const paramType = this.checker.getTypeOfSymbolAtLocation(param, node); parameterTypes.push(this.getType(paramType)); } // Create the Type.Method object const method = { kind: Type.Kind.Method, flags: 0, // FIXME - determine flags declaringType: declaringType, name: name, returnType: this.getType(returnType), parameterNames: parameterNames, parameterTypes: parameterTypes, thrownExceptions: [], // JavaScript doesn't have checked exceptions annotations: [], defaultValue: undefined, declaredFormalTypeNames: declaredFormalTypeNames, toJSON: function () { return Type.signature(this); } } as Type.Method; this.methodCache.set(cacheKey, method); return method; } private wrapperType(declaringType: (Type.FullyQualified & Type.Primitive) | Type.FullyQualified) { if (declaringType === Type.Primitive.String && this.stringWrapperType) { return this.getType(this.stringWrapperType) as Type.FullyQualified; } else if ((declaringType === Type.Primitive.Double || declaringType === Type.Primitive.BigInt) && this.numberWrapperType) { return this.getType(this.numberWrapperType) as Type.FullyQualified; } else if (declaringType === Type.Primitive.Boolean && this.booleanWrapperType) { return this.getType(this.booleanWrapperType) as Type.FullyQualified; } else { // This should not really happen, but we'll fallback to unknown if needed return Type.unknownType as Type.FullyQualified; } } methodType(node: ts.Node): Type.Method | undefined { let signature: ts.Signature | undefined; let methodName: string; let declaringType: Type.FullyQualified; let declaredFormalTypeNames: string[] = []; // Handle different kinds of nodes that represent methods or method invocations if (ts.isCallOrNewExpression(node)) { // For method invocations (e.g., _.map(...)) signature = this.checker.getResolvedSignature(node); if (!signature) { return undefined; } let symbol = this.checker.getSymbolAtLocation(node.expression); if (!symbol && ts.isPropertyAccessExpression(node.expression)) { // For property access expressions where we couldn't get a symbol, // try to get the symbol from the signature's declaration const declaration = signature?.getDeclaration(); if (declaration) { symbol = this.checker.getSymbolAtLocation(declaration); } // If still no symbol but we have a signature, we can proceed with limited info if (!symbol && signature) { // For cases like util.isArray where the module is 'any' type // We'll construct a basic method type from the signature methodName = node.expression.name.getText(); // When there's no symbol but we have a signature, we need to work harder // to find the declaring type. This happens with CommonJS require() calls // where the module is typed as 'any' but methods still have signatures // Try to trace back through the AST to find the require() call let inferredDeclaringType: Type.FullyQualified | undefined; const objExpr = node.expression.expression; if (ts.isIdentifier(objExpr)) { // Look for the variable declaration that assigns the require() result const objSymbol = this.checker.getSymbolAtLocation(objExpr); if (objSymbol && objSymbol.valueDeclaration) { const valueDecl = objSymbol.valueDeclaration; if (ts.isVariableDeclaration(valueDecl) && valueDecl.initializer) { // Check if it's a require() call if (ts.isCallExpression(valueDecl.initializer)) { const callExpr = valueDecl.initializer; if (ts.isIdentifier(callExpr.expression) && callExpr.expression.getText() === 'require' && callExpr.arguments.length > 0) { // Extract the module name from require('module-name') const moduleArg = callExpr.arguments[0]; if (ts.isStringLiteral(moduleArg)) { const moduleName = moduleArg.text; inferredDeclaringType = { kind: Type.Kind.Class, flags: 0, // TODO - determine flags fullyQualifiedName: moduleName } as Type.FullyQualified; } } } } } } // Use the inferred type or fall back to unknown declaringType = inferredDeclaringType || Type.unknownType as Type.FullyQualified; // Create the method type using the helper return this.createMethodType(signature, node, declaringType, methodName); } } if (!symbol) { return undefined; } // Get the method name if (ts.isPropertyAccessExpression(node.expression)) { methodName = node.expression.name.getText(); // Check if the object is an imported symbol const objSymbol = this.checker.getSymbolAtLocation(node.expression.expression); let isImport = false; if (objSymbol) { // Only call getAliasedSymbol if the symbol is actually an alias if (objSymbol.flags & ts.SymbolFlags.Alias) { const aliasedSymbol = this.checker.getAliasedSymbol(objSymbol); isImport = aliasedSymbol && aliasedSymbol !== objSymbol; } } const exprType = this.checker.getTypeAtLocation(node.expression.expression); const mappedType = this.getType(exprType); // Handle different types if (mappedType && mappedType.kind === Type.Kind.Class) { // Update the declaring type with the corrected FQN if (isImport && objSymbol) { const importName = objSymbol.getName(); const origFqn = (mappedType as Type.Class).fullyQualifiedName; const lastDot = origFqn.lastIndexOf('.'); if (lastDot > 0) { const typeName = origFqn.substring(lastDot + 1); declaringType = { kind: Type.Kind.Class, flags: 0, // TODO - determine flags fullyQualifiedName: `${importName}.${typeName}` } as Type.FullyQualified; } else { declaringType = mappedType as Type.FullyQualified; } } else { declaringType = mappedType as Type.FullyQualified; } } else if (mappedType && mappedType.kind === Type.Kind.Parameterized) { // For parameterized types (e.g., Array, number[]), use the base class type declaringType = (mappedType as Type.Parameterized).type; } else if (mappedType && mappedType.kind === Type.Kind.Primitive) { // Box the primitive to its wrapper type declaringType = this.wrapperType(mappedType as Type.Primitive); } else { // Default to unknown if we can't determine the type declaringType = Type.unknownType as Type.FullyQualified; } // For string methods like 'hello'.split(), ensure we have a proper declaring type for primitives if (!isImport && declaringType === Type.unknownType) { // If the expression type is a primitive string, use String as declaring type const typeString = this.checker.typeToString(exprType); if (typeString === 'string' || exprType.flags & ts.TypeFlags.String || exprType.flags & ts.TypeFlags.StringLiteral) { declaringType = this.wrapperType(Type.Primitive.String); } else if (typeString === 'number' || exprType.flags & ts.TypeFlags.Number || exprType.flags & ts.TypeFlags.NumberLiteral) { declaringType = this.wrapperType(Type.Primitive.Double); } else if (typeString === 'boolean' || exprType.flags & ts.TypeFlags.Boolean || exprType.flags & ts.TypeFlags.BooleanLiteral) { declaringType = this.wrapperType(Type.Primitive.Boolean); } else { // Fallback for other primitive types or unknown declaringType = Type.unknownType as Type.FullyQualified; } } } else if (ts.isIdentifier(node.expression)) { methodName = node.expression.getText(); // Check if this is an import first const exprSymbol = this.checker.getSymbolAtLocation(node.expression); let moduleSpecifier: string | undefined; let aliasedSymbol: ts.Symbol | undefined; if (exprSymbol) { // Check if this is an aliased symbol (i.e., an import) if (exprSymbol.flags & ts.SymbolFlags.Alias) { aliasedSymbol = this.checker.getAliasedSymbol(exprSymbol); } // If getAliasedSymbol returns something different, it's an import if (aliasedSymbol && aliasedSymbol !== exprSymbol) { // This is definitely an imported symbol const aliasedParentSymbol = (aliasedSymbol as any).parent as ts.Symbol | undefined; if (aliasedParentSymbol && aliasedParentSymbol.declarations?.[0] && ts.isModuleDeclaration(aliasedParentSymbol.declarations[0]) && ts.isIdentifier(aliasedParentSymbol.declarations[0].name)) { // For namespace imports, use the namespace symbol's `name` as the module specifier (e.g. `React` instead of `react`) moduleSpecifier = aliasedParentSymbol.name; } else { // Now find the import declaration to get the module specifier if (exprSymbol.declarations && exprSymbol.declarations.length > 0) { let importNode: ts.Node = exprSymbol.declarations[0]; // Traverse up to find the ImportDeclaration while (importNode && !ts.isImportDeclaration(importNode)) { importNode = importNode.parent; } if (importNode && ts.isImportDeclaration(importNode)) { const importDeclNode = importNode as ts.ImportDeclaration; if (ts.isStringLiteral(importDeclNode.moduleSpecifier)) { moduleSpecifier = importDeclNode.moduleSpecifier.text; } } } } } } if (moduleSpecifier) { // This is an imported function - use the module specifier as declaring type if (moduleSpecifier.startsWith('node:')) { // Node.js built-in module declaringType = { kind: Type.Kind.Class, flags: 0, // TODO - determine flags fullyQualifiedName: 'node' } as Type.FullyQualified; methodName = moduleSpecifier.substring(5); // Remove 'node:' prefix } else { // Regular module import declaringType = { kind: Type.Kind.Class, flags: 0, // TODO - determine flags fullyQualifiedName: moduleSpecifier } as Type.FullyQualified; // A default import binds an `ImportClause`; its aliased symbol carries the internal // name of the default export (e.g. `e` for express), so represent it as ``. // Named imports (`ImportSpecifier`) keep the original exported name. const isDefaultImport = exprSymbol?.declarations?.some(ts.isImportClause) ?? false; if (!isDefaultImport && aliasedSymbol && aliasedSymbol.name) { methodName = aliasedSymbol.name; } else { methodName = ''; } } } else { // Fall back to the original logic for non-imported functions const exprType = this.checker.getTypeAtLocation(node.expression); const funcType = this.getType(exprType); if (funcType && funcType.kind === Type.Kind.Class) { const fqn = (funcType as Type.Class).fullyQualifiedName; const lastDot = fqn.lastIndexOf('.'); if (lastDot > 0) { // For functions from modules, use the module part as declaring type declaringType = { kind: Type.Kind.Class, flags: 0, // TODO - determine flags fullyQualifiedName: fqn.substring(0, lastDot) } as Type.FullyQualified; } else { // No dots in the name - the type IS the module itself declaringType = funcType as Type.FullyQualified; } } else { // Try to use the symbol's parent or module const parent = (symbol as any).parent; if (parent) { const parentType = this.checker.getDeclaredTypeOfSymbol(parent); declaringType = this.getType(parentType) as Type.FullyQualified; } else { declaringType = Type.unknownType as Type.FullyQualified; } } } } else { methodName = symbol.getName(); declaringType = Type.unknownType as Type.FullyQualified; } // Get type parameters from signature const typeParameters = signature.getTypeParameters(); if (typeParameters) { for (const tp of typeParameters) { declaredFormalTypeNames.push(tp.symbol.getName()); } } } else if (ts.isMethodDeclaration(node) || ts.isMethodSignature(node)) { // For method declarations const symbol = this.checker.getSymbolAtLocation(node.name!); if (!symbol) { return undefined; } signature = this.checker.getSignatureFromDeclaration(node); if (!signature) { return undefined; } methodName = symbol.getName(); // Get the declaring type (the class/interface that contains this method) const parent = node.parent; if (ts.isClassDeclaration(parent) || ts.isInterfaceDeclaration(parent) || ts.isObjectLiteralExpression(parent)) { const parentType = this.checker.getTypeAtLocation(parent); declaringType = this.getType(parentType) as Type.FullyQualified; } else { declaringType = Type.unknownType as Type.FullyQualified; } // Get type parameters from node if (node.typeParameters) { for (const tp of node.typeParameters) { declaredFormalTypeNames.push(tp.name.getText()); } } } else if (ts.isConstructorDeclaration(node)) { // For constructor declarations signature = this.checker.getSignatureFromDeclaration(node); if (!signature) { return undefined; } methodName = ""; const parent = node.parent; if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) { const parentType = this.checker.getTypeAtLocation(parent); declaringType = this.getType(parentType) as Type.FullyQualified; } else { declaringType = Type.unknownType as Type.FullyQualified; } } else if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) { // For function declarations/expressions signature = this.checker.getSignatureFromDeclaration(node); if (!signature) { return undefined; } methodName = node.name ? node.name.getText() : ""; // Derive declaring type from source file module path (like Go's type_mapper.go). // Use the same relativization as getFullyQualifiedName() so that declarations // and invocations produce matching FQNs. let moduleFqn: string; const fileName = node.getSourceFile().fileName; if (this.sourceRoot && path.isAbsolute(fileName)) { moduleFqn = path.relative(this.sourceRoot, fileName); } else { moduleFqn = fileName; } // Strip file extension to get the module name moduleFqn = moduleFqn.replace(/\.[^/.]+$/, ''); declaringType = { kind: Type.Kind.Class, flags: 0, fullyQualifiedName: moduleFqn } as Type.FullyQualified; // Get type parameters from node if (node.typeParameters) { for (const tp of node.typeParameters) { declaredFormalTypeNames.push(tp.name.getText()); } } } else { // For other node types, return undefined return undefined; } // Create the method type using the helper return this.createMethodType(signature, node, declaringType, methodName, declaredFormalTypeNames); } /** * Get the fully qualified name for a TypeScript type. * Uses TypeScript's built-in resolution which properly handles things like: * - React.Component (not @types/react.Component) * - _.LoDashStatic (not @types/lodash.LoDashStatic) */ private getFullyQualifiedName(type: ts.Type): string { const symbol = type.getSymbol?.(); if (!symbol) { return "unknown"; } return this.getFullyQualifiedNameFromSymbol(symbol); } private getFullyQualifiedNameFromSymbol(symbol: ts.Symbol): string { // First, check if this symbol is an import/alias // For imported types, we want to use the module specifier instead of the file path if (symbol.flags & ts.SymbolFlags.Alias) { const aliasedSymbol = this.checker.getAliasedSymbol(symbol); if (aliasedSymbol && aliasedSymbol !== symbol && symbol.declarations && symbol.declarations.length > 0) { // Try to find the import declaration to get the module specifier let importNode: ts.Node | undefined = symbol.declarations[0]; // Traverse up to find the ImportDeclaration or ImportSpecifier while (importNode && importNode.parent && !ts.isImportDeclaration(importNode) && !ts.isImportSpecifier(importNode)) { importNode = importNode.parent; } let moduleSpecifier: string | undefined; if (importNode && ts.isImportSpecifier(importNode)) { // Named import like: import { ClipLoader } from 'react-spinners' // ImportSpecifier -> NamedImports -> ImportClause -> ImportDeclaration const namedImports = importNode.parent; // NamedImports if (namedImports && ts.isNamedImports(namedImports)) { const importClause = namedImports.parent; // ImportClause if (importClause && ts.isImportClause(importClause)) { const importDecl = importClause.parent; // ImportDeclaration if (importDecl && ts.isImportDeclaration(importDecl) && ts.isStringLiteral(importDecl.moduleSpecifier)) { moduleSpecifier = importDecl.moduleSpecifier.text; } } } } else if (importNode && ts.isImportDeclaration(importNode)) { // Default or namespace import if (ts.isStringLiteral(importNode.moduleSpecifier)) { moduleSpecifier = importNode.moduleSpecifier.text; } } if (moduleSpecifier) { // Build the fully qualified name from module specifier + symbol name const symbolName = symbol.getName(); return `${moduleSpecifier}.${symbolName}`; } } } // Fall back to TypeScript's built-in getFullyQualifiedName // This returns names with quotes that we need to clean up // e.g., '"React"."Component"' -> 'React.Component' const tsQualifiedName = this.checker.getFullyQualifiedName(symbol); let cleanedName = tsQualifiedName.replace(/"/g, ''); // Check if this is a file path from node_modules (happens with some packages) // TypeScript sometimes returns full paths instead of module names if (cleanedName.includes('node_modules/')) { // Extract the module name from the path // Example: /private/var/.../node_modules/react-spinners/src/index.ClipLoader // Should become: react-spinners.ClipLoader const nodeModulesIndex = cleanedName.indexOf('node_modules/'); const afterNodeModules = cleanedName.substring(nodeModulesIndex + 'node_modules/'.length); // Split by '/' to get parts of the path const pathParts = afterNodeModules.split('/'); if (pathParts.length > 0) { // First part is the package name (might be scoped like @types) let packageName = pathParts[0]; // Handle scoped packages if (packageName.startsWith('@') && pathParts.length > 1) { packageName = `${packageName}/${pathParts[1]}`; } // Normalize `@types/` to the importable specifier `` so that types // reached transitively (e.g. a call's return type, resolved via the declaration // file's node_modules path) match the names used for directly imported types. packageName = this.normalizePackageName(packageName); // Find the symbol name (everything after the last dot in the original cleaned name) const lastDotIndex = cleanedName.lastIndexOf('.'); if (lastDotIndex > 0) { const symbolName = cleanedName.substring(lastDotIndex + 1); cleanedName = `${packageName}.${symbolName}`; } else { cleanedName = packageName; } } } else if (path.isAbsolute(cleanedName)) { // TypeScript returns absolute file paths as module names for project source files // that are ES modules (have imports/exports). Relativize using sourceRoot. // Example: /var/moderne/.../BookStack/resources/js/foo.MyClass // Should become: resources/js/foo.MyClass if (this.sourceRoot) { cleanedName = path.relative(this.sourceRoot, cleanedName); } } else if (!cleanedName.includes('.') && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum))) { // Bare class/interface/enum name whose declaring package was dropped (e.g. `Logger` // from a package that does `export = Logger`). TypeScript's getFullyQualifiedName drops // the package here because the symbol has no `parent`, so the package would otherwise be // lost. Recover it from the symbol's declaration file — but only for genuine module // exports from node_modules. Excluded by construction: // - namespace/module objects (e.g. the `React` namespace), whose bare name is intentional // and which are not Class/Interface/Enum symbols; // - local declarations (extractModuleNameFromPath returns undefined); // - ambient globals (which are `global.`-qualified, hence not bare). const declarationFile = symbol.declarations?.[0]?.getSourceFile(); if (declarationFile && ts.isExternalModule(declarationFile)) { const packageName = this.extractModuleNameFromPath(declarationFile.fileName); if (packageName) { cleanedName = `${packageName}.${cleanedName}`; } } } else if (cleanedName.includes('.')) { // Namespace-qualified name such as `Bull.Queue` or `request.SuperAgentStatic`, where // the leading segment is the package's internal `export =` namespace rather than the // importable package name. Replace it with the declaring package so the FQN identifies // the package the type actually comes from. // // Preserved as-is: // - UMD globals (`export as namespace X`, e.g. React, lodash `_`, jQuery `$`): the // namespace name is the conventional public identifier; // - `global.*` (TypeScript's marker for ambient globals); // - namespaces that already match the package name, and any non-node_modules type. const namespaceName = cleanedName.substring(0, cleanedName.indexOf('.')); if (namespaceName !== 'global') { const declarationFile = symbol.declarations?.[0]?.getSourceFile(); if (declarationFile && ts.isExternalModule(declarationFile)) { const packageName = this.extractModuleNameFromPath(declarationFile.fileName); if (packageName && packageName !== namespaceName && !this.isUmdGlobalNamespace(declarationFile, namespaceName)) { cleanedName = packageName + cleanedName.substring(cleanedName.indexOf('.')); } } } } return cleanedName.endsWith('Constructor') ? cleanedName.substring(0, cleanedName.length - 'Constructor'.length) : cleanedName; } /** * Whether {@code namespaceName} is exposed as a UMD global from the given declaration file * (e.g. `export as namespace React`). The namespace name of a UMD global is the conventional * public identifier (React, lodash `_`, jQuery `$`) and must be preserved rather than replaced * with the package name. */ private isUmdGlobalNamespace(sourceFile: ts.SourceFile, namespaceName: string): boolean { for (const statement of sourceFile.statements) { if (ts.isNamespaceExportDeclaration(statement) && statement.name.text === namespaceName) { return true; } } return false; } /** * Create an empty JavaType.Class shell from a TypeScript type. * The shell will be populated later to handle circular references. */ private createEmptyClassType(type: ts.Type): Type.Class { // Use our getFullyQualifiedName method which uses TypeScript's built-in resolution const fullyQualifiedName = this.getFullyQualifiedName(type); // Determine the class kind based on symbol flags let classKind = Type.Class.Kind.Interface; // Default to interface const symbol = type.getSymbol?.(); if (symbol) { if (symbol.flags & ts.SymbolFlags.Class) { classKind = Type.Class.Kind.Class; } else if (symbol.flags & ts.SymbolFlags.Enum) { classKind = Type.Class.Kind.Enum; } else if (symbol.flags & ts.SymbolFlags.Interface) { classKind = Type.Class.Kind.Interface; } } // Create empty class type shell (no members yet to avoid recursion) return { kind: Type.Kind.Class, flags: 0, // TODO - determine flags classKind: classKind, fullyQualifiedName: fullyQualifiedName, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; } /** * Populates the class type with members, methods, heritage, and type parameters * Since the shell is already in the cache, any recursive references will find it */ private populateClassType(classType: Type.Class, type: ts.Type): void { const symbol = type.getSymbol?.(); // Try to get base types using TypeScript's getBaseTypes API // This works for both local and external types (from node_modules) if (type.flags & ts.TypeFlags.Object) { let baseTypes: ts.Type[] | undefined; // Check if this is a class or interface type that supports getBaseTypes const objectType = type as ts.ObjectType; if (objectType.objectFlags & (ts.ObjectFlags.Class | ts.ObjectFlags.Interface)) { try { baseTypes = (this.checker as any).getBaseTypes?.(type as ts.InterfaceType); } catch (e) { // getBaseTypes might fail for some types, fall back to declaration-based extraction } } else if (symbol) { // For constructor functions or type references, we need to get the actual class type // Try to get the type of the class itself (not the constructor or instance) const classSymbol = symbol.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(symbol) : symbol; if (classSymbol && classSymbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) { // Get the type of the class declaration itself const declaredType = this.checker.getDeclaredTypeOfSymbol(classSymbol); if (declaredType && declaredType !== type) { try { baseTypes = (this.checker as any).getBaseTypes?.(declaredType as ts.InterfaceType); } catch (e) { // getBaseTypes might fail, fall back to declaration-based extraction } } } else if (classSymbol && classSymbol.valueDeclaration && ts.isClassDeclaration(classSymbol.valueDeclaration)) { // Handle the case where the symbol is for a class value (constructor function) // Get the instance type of the class const instanceType = this.checker.getDeclaredTypeOfSymbol(classSymbol); if (instanceType && instanceType !== type) { try { baseTypes = (this.checker as any).getBaseTypes?.(instanceType as ts.InterfaceType); } catch (e) { // getBaseTypes might fail, fall back to declaration-based extraction } } } } if (baseTypes && baseTypes.length > 0) { // For classes, the first base type is usually the superclass // Additional base types are interfaces if (classType.classKind === Type.Class.Kind.Class) { const firstBase = this.getType(baseTypes[0]); // Handle both Class and Parameterized (e.g., Component) if (Type.isClass(firstBase)) { (classType as any).supertype = firstBase; } else if (Type.isParameterized(firstBase)) { // For parameterized types, use the base class as the supertype (classType as any).supertype = (firstBase as Type.Parameterized).type; } // Rest are interfaces for (let i = 1; i < baseTypes.length; i++) { const interfaceType = this.getType(baseTypes[i]); if (Type.isClass(interfaceType)) { classType.interfaces.push(interfaceType); } } } else { // For interfaces, all base types are extended interfaces for (const baseType of baseTypes) { const interfaceType = this.getType(baseType); if (Type.isClass(interfaceType)) { classType.interfaces.push(interfaceType); } } } } } // Extract type parameters from declarations (not provided by getBaseTypes) if (symbol?.declarations) { for (const declaration of symbol.declarations) { if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) { // Extract type parameters if (declaration.typeParameters) { for (const tp of declaration.typeParameters) { const tpType = this.checker.getTypeAtLocation(tp); classType.typeParameters.push(this.getType(tpType)); } } break; // Only process the first declaration } } } // Get properties and methods const properties = this.checker.getPropertiesOfType(type); for (const prop of properties) { const declaration = prop.valueDeclaration || prop.declarations?.[0]; if (!declaration) { // Skip properties without declarations (synthetic/built-in properties) continue; } if (prop.flags & ts.SymbolFlags.Method) { // TODO: Create Type.Method when method support is added // For now, skip methods continue; } else { // Create Type.Variable for fields/properties const propType = this.checker.getTypeOfSymbolAtLocation(prop, declaration); const variable: Type.Variable = { kind: Type.Kind.Variable, name: prop.getName(), owner: classType, // Cyclic reference to the containing class (already in cache) type: this.getType(propType), // This will find classType in cache if it's recursive annotations: [], toJSON: function () { return Type.signature(this); } } as Type.Variable; classType.members.push(variable); } } } /** * Note: Object/Class/Interface types are handled in getType() to properly manage circular references * This method should only be called for primitive and unknown types */ private createPrimitiveOrUnknownType(type: ts.Type): Type { // Check for literals first if (type.isLiteral()) { if (type.isNumberLiteral()) { return Type.Primitive.Double; } else if (type.isStringLiteral()) { return Type.Primitive.String; } } // Check for primitive types // Note: Using bitwise & instead of === for robustness, as TypeScript may assign // multiple flags to a single type (e.g., Boolean + Union) if (type.flags & ts.TypeFlags.Null) { return Type.Primitive.Null; } else if (type.flags & ts.TypeFlags.Undefined) { return Type.Primitive.None; } else if (type.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral | ts.TypeFlags.NumberLike)) { return Type.Primitive.Double; } else if (type.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral | ts.TypeFlags.StringLike)) { return Type.Primitive.String; } else if (type.flags & ts.TypeFlags.Void) { return Type.Primitive.Void; } else if (type.flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral | ts.TypeFlags.BigIntLike)) { return Type.Primitive.BigInt; } else if (type.symbol !== undefined && type.symbol === this.regExpSymbol) { return Type.Primitive.String; } else if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.BooleanLike)) { return Type.Primitive.Boolean; } // Check for type aliases that may resolve to primitives const symbol = type.getSymbol?.(); if (symbol && symbol.flags & ts.SymbolFlags.TypeAlias) { // Type aliases may resolve to primitive types const aliasedType = this.checker.getDeclaredTypeOfSymbol(symbol); if (aliasedType !== type) { return this.getType(aliasedType); } } return Type.unknownType; } /** * Create a union type from TypeScript union type (e.g., string | number) * Note: Cache check is done in getType() before calling this method */ private createUnionType(unionType: ts.UnionType, cacheKey: string | number): Type.Union { // Shell-cache FIRST to prevent infinite recursion (before resolving constituent types) const union = { kind: Type.Kind.Union, bounds: [] } as Type.Union; this.typeCache.set(cacheKey, union); // Now map all constituent types (may recursively reference this union) const bounds: Type[] = []; for (const constituentType of unionType.types) { bounds.push(this.getType(constituentType)); } // Update the bounds in the union we created (union as any).bounds = bounds; return union; } /** * Create an intersection type from TypeScript intersection type (e.g., A & B) * Note: Cache check is done in getType() before calling this method */ private createIntersectionType(intersectionType: ts.IntersectionType, cacheKey: string | number): Type.Intersection { // Shell-cache FIRST to prevent infinite recursion (before resolving constituent types) const intersection = { kind: Type.Kind.Intersection, bounds: [] } as Type.Intersection; this.typeCache.set(cacheKey, intersection); // Now map all constituent types (may recursively reference this intersection) const bounds: Type[] = []; for (const constituentType of intersectionType.types) { bounds.push(this.getType(constituentType)); } // Update the bounds in the intersection we created (intersection as any).bounds = bounds; return intersection; } /** * Create a generic type variable from a TypeScript type parameter. * Examples: T, K extends string, V extends keyof T * Note: Cache check is done in getType() before calling this method */ private createGenericTypeVariable(typeParam: ts.TypeParameter, cacheKey: string | number): Type.GenericTypeVariable { const symbol = typeParam.getSymbol(); const name = symbol ? symbol.getName() : '?'; // Shell-cache: Create stub, cache it, then populate (prevents cycles) const gtv = { kind: Type.Kind.GenericTypeVariable, name: name, variance: Type.GenericTypeVariable.Variance.Invariant, bounds: [] } as Type.GenericTypeVariable; this.typeCache.set(cacheKey, gtv); // Get the constraint (upper bound) if it exists const constraint = typeParam.getConstraint(); let bounds: Type[] = []; let variance = Type.GenericTypeVariable.Variance.Invariant; if (constraint) { const boundType = this.getType(constraint); // Only add bounds if it's not just "object" (the default constraint) if (!(Type.isClass(boundType) && boundType.fullyQualifiedName === 'object')) { bounds = [boundType]; variance = Type.GenericTypeVariable.Variance.Covariant; } } // Update the variance and bounds (gtv as any).variance = variance; (gtv as any).bounds = bounds; return gtv; } /** * Create an empty function type shell with FQN 𝑓. * The shell will be populated later to handle circular references. */ private createEmptyFunctionType(): Type.Class { return { kind: Type.Kind.Class, flags: 0, classKind: Type.Class.Kind.Interface, fullyQualifiedName: FUNCTION_TYPE_NAME, typeParameters: [], annotations: [], interfaces: [], members: [], methods: [], toJSON: function () { return Type.signature(this); } } as Type.Class; } /** * Populate a function type with signature information. * The function type has generic type parameters for return type (first) and parameter types (subsequent), * and contains an apply() method with the matching signature. * Since the shell is already in the cache, any recursive references will find it. */ private populateFunctionType(functionClass: Type.Class, signature: ts.Signature): void { const returnType = this.getType(signature.getReturnType()); const parameters = signature.getParameters(); const parameterTypes: Type[] = []; const parameterNames: string[] = []; // Get parameter types for (const param of parameters) { const declaration = param.valueDeclaration || param.declarations?.[0]; if (declaration) { const paramType = this.checker.getTypeOfSymbolAtLocation(param, declaration); parameterTypes.push(this.getType(paramType)); parameterNames.push(param.getName()); } } // Build the type parameters list with proper variance: // - Return type is covariant (R) // - Parameter types are contravariant (P1, P2, ...) const typeParameters: Type[] = []; // Return type parameter (covariant) typeParameters.push({ kind: Type.Kind.GenericTypeVariable, name: 'R', variance: Type.GenericTypeVariable.Variance.Covariant, bounds: [returnType] } as Type.GenericTypeVariable); // Parameter type variables (contravariant) parameterTypes.forEach((paramType, index) => { typeParameters.push({ kind: Type.Kind.GenericTypeVariable, name: `P${index + 1}`, variance: Type.GenericTypeVariable.Variance.Contravariant, bounds: [paramType] } as Type.GenericTypeVariable); }); functionClass.typeParameters = typeParameters; // Create the apply() method const applyMethod = { kind: Type.Kind.Method, flags: 0, declaringType: functionClass, name: 'apply', returnType: returnType, parameterNames: parameterNames, parameterTypes: parameterTypes, thrownExceptions: [], annotations: [], defaultValue: undefined, declaredFormalTypeNames: [], toJSON: function () { return Type.signature(this); } } as Type.Method; // Add the apply method to the function class functionClass.methods.push(applyMethod); } }