From 7ab92a4700e53a1ce440b65df0a3099e7cb2e021 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 21 Aug 2026 15:24:25 +0300 Subject: [PATCH 1/9] [ETS] Materialize object type literals --- .../ts-frontend/src/lowering/fileBuilder.ts | 2 +- jacodb-ets/ts-frontend/src/types/convert.ts | 67 ++++++++++++++++++- jacodb-ets/ts-frontend/test/types.spec.ts | 29 +++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 2b853f0b9..3d0760286 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -119,7 +119,7 @@ class FileBuilder { build(sourceFile: ts.SourceFile): EtsFileDto { const contents = this.buildScope(sourceFile.statements, undefined); - contents.classes.push(...this.ctx.anonymous.classes); + contents.classes.push(...this.ctx.anonymous.classes, ...this.ctx.converter.structuralClasses); // Anonymous closure methods retain the enclosing class so lexical // `this` has the same type as in the source method. Add anonymous // classes first because their methods may themselves contain closures. diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index 576feb797..a667d1c2e 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -29,7 +29,8 @@ */ import * as ts from "typescript"; -import { PATTERN_PARAMETER_PREFIX } from "../dto/constants"; +import { ClassCategory, PATTERN_PARAMETER_PREFIX } from "../dto/constants"; +import { ClassDto, FieldDto } from "../dto/model"; import { ClassSignatureDto, FileSignatureDto, @@ -54,11 +55,15 @@ import { UnclearReferenceTypeDto, VOID_TYPE, } from "../dto/types"; +import { decoratorsOf, memberName, modifiersOf } from "../lowering/astUtils"; /** Guard against deeply nested / self-referential types. */ const MAX_DEPTH = 8; export class TypeConverter { + readonly structuralClasses: ClassDto[] = []; + private readonly structuralClassByNode = new Map(); + constructor( private readonly checker: ts.TypeChecker, private readonly fileSignatureFor: (sf: ts.SourceFile) => FileSignatureDto, @@ -181,6 +186,9 @@ export class TypeConverter { signature: this.functionSignatureFromTypeNode(node, depth, substitutions), }; } + if (ts.isTypeLiteralNode(node)) { + return this.convertTypeLiteralNode(node, depth, substitutions); + } if (ts.isTypeReferenceNode(node)) { return this.convertTypeReference(node, depth, substitutions); } @@ -191,6 +199,63 @@ export class TypeConverter { return UNKNOWN_TYPE; } + private convertTypeLiteralNode( + node: ts.TypeLiteralNode, + depth: number, + substitutions?: ReadonlyMap, + ): TypeDto { + const members: ts.PropertySignature[] = []; + for (const member of node.members) { + if ( + !ts.isPropertySignature(member) || + member.name === undefined || + ts.isComputedPropertyName(member.name) + ) { + return UNKNOWN_TYPE; + } + members.push(member); + } + + const existing = this.structuralClassByNode.get(node); + if (existing !== undefined) { + return { _: "ClassType", signature: existing.signature }; + } + + const signature: ClassSignatureDto = { + name: `%ST${node.getStart(node.getSourceFile())}`, + declaringFile: this.fileSignatureFor(node.getSourceFile()), + }; + const structuralClass: ClassDto = { + signature, + modifiers: 0, + decorators: [], + category: ClassCategory.TYPE_LITERAL, + superClassName: "", + implementedInterfaceNames: [], + fields: [], + methods: [], + }; + + // Register the shell before converting fields so recursive aliases such as + // `type Node = { next?: Node }` resolve back to the same structural class. + this.structuralClassByNode.set(node, structuralClass); + this.structuralClasses.push(structuralClass); + + structuralClass.fields = members.map((member): FieldDto => ({ + signature: { + declaringClass: signature, + name: memberName(member.name), + type: this.convertTypeNode(member.type, depth + 1, substitutions), + }, + modifiers: modifiersOf(member), + decorators: decoratorsOf(member), + questionToken: member.questionToken !== undefined, + exclamationToken: false, + })); + + return { _: "ClassType", signature }; + } + private convertLiteralTypeNode(node: ts.LiteralTypeNode): TypeDto { const literal = node.literal; if (literal.kind === ts.SyntaxKind.NullKeyword) { diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index 9c3726959..423f7601e 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -177,6 +177,34 @@ describe("convertTypeNode (annotations)", () => { }); }); + it("materializes object type literals as structural classes", () => { + const { file } = lower(` + class C { + read(value: { required: number; optional?: string }): number { + return value.required; + } + } + `); + const parameterType = methodByName(file, "read").signature.parameters[0].type; + expect(parameterType._).toBe("ClassType"); + if (parameterType._ !== "ClassType") throw new Error("expected a structural class type"); + + const structuralClass = file.classes.find( + (candidate) => candidate.signature.name === parameterType.signature.name, + ); + expect(structuralClass).toBeDefined(); + expect(structuralClass?.fields).toEqual([ + expect.objectContaining({ + signature: expect.objectContaining({ name: "required", type: { _: "NumberType" } }), + questionToken: false, + }), + expect.objectContaining({ + signature: expect.objectContaining({ name: "optional", type: { _: "StringType" } }), + questionToken: true, + }), + ]); + }); + it("resolves namespace-qualified names with the namespace chain", () => { const type = annotationOf("namespace N { export class C {} }\nlet x: N.C;"); expect(type).toEqual({ @@ -203,7 +231,6 @@ describe("convertTypeNode (annotations)", () => { it("degrades exotic types to UnknownType", () => { expect(annotationOf("let x: keyof { a: number };")).toEqual({ _: "UnknownType" }); - expect(annotationOf("let x: { a: number };")).toEqual({ _: "UnknownType" }); expect(annotationOf("let x;")).toEqual({ _: "UnknownType" }); expect(annotationOf("let x: `a${string}`;")).toEqual({ _: "StringType" }); }); From 80a8a9bc37009d7ff22103080cc77459f14431e0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 21 Aug 2026 15:31:39 +0300 Subject: [PATCH 2/9] [ETS] Preserve the object keyword type --- jacodb-ets/ts-frontend/src/types/convert.ts | 13 ++++++++++++- jacodb-ets/ts-frontend/test/types.spec.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index a667d1c2e..8658f0e83 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -62,7 +62,7 @@ const MAX_DEPTH = 8; export class TypeConverter { readonly structuralClasses: ClassDto[] = []; - private readonly structuralClassByNode = new Map(); + private readonly structuralClassByNode = new Map(); constructor( private readonly checker: ts.TypeChecker, @@ -146,6 +146,8 @@ export class TypeConverter { return NUMBER_TYPE; case ts.SyntaxKind.StringKeyword: return STRING_TYPE; + case ts.SyntaxKind.ObjectKeyword: + return this.materializeStructuralClass(node, [], depth, substitutions); case ts.SyntaxKind.VoidKeyword: return VOID_TYPE; case ts.SyntaxKind.NeverKeyword: @@ -216,6 +218,15 @@ export class TypeConverter { members.push(member); } + return this.materializeStructuralClass(node, members, depth, substitutions); + } + + private materializeStructuralClass( + node: ts.TypeNode, + members: readonly ts.PropertySignature[], + depth: number, + substitutions?: ReadonlyMap, + ): ClassTypeDto { const existing = this.structuralClassByNode.get(node); if (existing !== undefined) { return { _: "ClassType", signature: existing.signature }; diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index 423f7601e..e434f91c9 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -1,5 +1,6 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; +import { ClassCategory } from "../src/dto/constants"; import { FileSignatureDto } from "../src/dto/signatures"; import { TypeDto } from "../src/dto/types"; import { TypeConverter } from "../src/types/convert"; @@ -205,6 +206,22 @@ describe("convertTypeNode (annotations)", () => { ]); }); + it("materializes the object keyword as an empty structural class", () => { + const { file } = lower("function read(value: object): void {}"); + const parameterType = methodByName(file, "read").signature.parameters[0].type; + expect(parameterType._).toBe("ClassType"); + if (parameterType._ !== "ClassType") throw new Error("expected a structural class type"); + + expect(file.classes).toContainEqual( + expect.objectContaining({ + signature: parameterType.signature, + category: ClassCategory.TYPE_LITERAL, + fields: [], + methods: [], + }), + ); + }); + it("resolves namespace-qualified names with the namespace chain", () => { const type = annotationOf("namespace N { export class C {} }\nlet x: N.C;"); expect(type).toEqual({ From bb552c372db7e32be766daa43d4265eb4f22d80f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 15:44:00 +0300 Subject: [PATCH 3/9] [ETS] Emit structural types in declaring files --- .../ts-frontend/src/lowering/fileBuilder.ts | 6 +++- jacodb-ets/ts-frontend/test/cli.spec.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 3d0760286..8f52b37ce 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -119,7 +119,11 @@ class FileBuilder { build(sourceFile: ts.SourceFile): EtsFileDto { const contents = this.buildScope(sourceFile.statements, undefined); - contents.classes.push(...this.ctx.anonymous.classes, ...this.ctx.converter.structuralClasses); + const ownedStructuralClasses = this.ctx.converter.structuralClasses.filter(({ signature }) => + signature.declaringFile.projectName === this.fileSignature.projectName + && signature.declaringFile.fileName === this.fileSignature.fileName, + ); + contents.classes.push(...this.ctx.anonymous.classes, ...ownedStructuralClasses); // Anonymous closure methods retain the enclosing class so lexical // `this` has the same type as in the source method. Add anonymous // classes first because their methods may themselves contain closures. diff --git a/jacodb-ets/ts-frontend/test/cli.spec.ts b/jacodb-ets/ts-frontend/test/cli.spec.ts index 105b6244a..00aedd05e 100644 --- a/jacodb-ets/ts-frontend/test/cli.spec.ts +++ b/jacodb-ets/ts-frontend/test/cli.spec.ts @@ -3,6 +3,7 @@ import * as os from "os"; import * as path from "path"; import * as ts from "typescript"; import { afterEach, describe, expect, it } from "vitest"; +import { ClassCategory } from "../src/dto/constants"; import { main, parseArgs, resolveProjectInputs } from "../src/index"; const tempDirs: string[] = []; @@ -126,6 +127,34 @@ describe("project mode", () => { }); }); + it("emits an imported structural type only in its declaring file", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-structural-owner-")); + tempDirs.push(projectDir); + fs.writeFileSync( + path.join(projectDir, "a.ts"), + "export type Shared = { value: number }; export function fromA(value: Shared): number { return value.value; }", + ); + fs.writeFileSync( + path.join(projectDir, "b.ts"), + 'import { Shared } from "./a"; export function fromB(value: Shared): number { return value.value; }', + ); + + const outputDir = path.join(projectDir, "ir"); + expect(main(["--project", projectDir, outputDir])).toBe(0); + const declaringFile = JSON.parse(fs.readFileSync(path.join(outputDir, "a.ts.json"), "utf8")); + const importingFile = JSON.parse(fs.readFileSync(path.join(outputDir, "b.ts.json"), "utf8")); + const structuralClasses = [...declaringFile.classes, ...importingFile.classes].filter( + (candidate: { category?: number }) => candidate.category === ClassCategory.TYPE_LITERAL, + ); + + expect(structuralClasses).toHaveLength(1); + expect(structuralClasses[0].signature.declaringFile.fileName).toBe("a.ts"); + const fromB = importingFile.classes[0].methods.find( + (method: { signature: { name: string } }) => method.signature.name === "fromB", + ); + expect(fromB.signature.parameters[0].type.signature).toEqual(structuralClasses[0].signature); + }); + it("keeps an in-root filename beginning with two dots project-owned", () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-dotdot-file-")); tempDirs.push(projectDir); From 14a295dfe9aa40a8ae51cfcb0c95a68e8825001b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 15:47:00 +0300 Subject: [PATCH 4/9] [ETS] Preserve generic structural aliases --- .../ts-frontend/src/lowering/fileBuilder.ts | 6 +++ jacodb-ets/ts-frontend/src/types/convert.ts | 40 +++++++++++++++++++ jacodb-ets/ts-frontend/test/types.spec.ts | 23 +++++++++++ 3 files changed, 69 insertions(+) diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 8f52b37ce..4d9ddf4e2 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -300,6 +300,12 @@ class FileBuilder { const classes: ClassDto[] = []; const namespaces: NamespaceDto[] = []; + for (const statement of statements) { + if (ts.isTypeAliasDeclaration(statement)) { + this.ctx.converter.materializeStructuralAlias(statement); + } + } + const defaultClassSignature: ClassSignatureDto = { name: DEFAULT_ARK_CLASS_NAME, declaringFile: this.fileSignature, diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index 8658f0e83..d0e4da345 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -221,6 +221,22 @@ export class TypeConverter { return this.materializeStructuralClass(node, members, depth, substitutions); } + /** Materialize a structural alias in its declaring file before any use-site specialization. */ + materializeStructuralAlias(decl: ts.TypeAliasDeclaration): void { + const target = unwrapParenthesizedType(decl.type); + if (!ts.isTypeLiteralNode(target) && target.kind !== ts.SyntaxKind.ObjectKeyword) { + return; + } + const type = this.convertTypeNode(target); + if (type._ !== "ClassType") { + return; + } + const structuralClass = this.structuralClassByNode.get(target); + if (structuralClass !== undefined) { + structuralClass.typeParameters = this.convertTypeParameters(decl.typeParameters); + } + } + private materializeStructuralClass( node: ts.TypeNode, members: readonly ts.PropertySignature[], @@ -340,6 +356,22 @@ export class TypeConverter { aliasSubstitutions.delete(parameter); } }); + const structuralTarget = unwrapParenthesizedType(aliasDecl.type); + if (ts.isTypeLiteralNode(structuralTarget) || structuralTarget.kind === ts.SyntaxKind.ObjectKeyword) { + const definitionSubstitutions = new Map(substitutions); + aliasDecl.typeParameters?.forEach((parameter) => definitionSubstitutions.delete(parameter)); + const result = this.convertTypeNode(structuralTarget, depth + 1, definitionSubstitutions); + if (result._ === "ClassType") { + const structuralClass = this.structuralClassByNode.get(structuralTarget); + if (structuralClass !== undefined) { + structuralClass.typeParameters = this.convertTypeParameters(aliasDecl.typeParameters); + } + if (typeArgs !== undefined && typeArgs.length > 0) { + result.typeParameters = typeArgs; + } + } + return result; + } return this.convertTypeNode(aliasDecl.type, depth + 1, aliasSubstitutions); } } @@ -661,6 +693,14 @@ function unwrapTupleMember(node: ts.TypeNode): ts.TypeNode { return node; } +function unwrapParenthesizedType(node: ts.TypeNode): ts.TypeNode { + let current = node; + while (ts.isParenthesizedTypeNode(current)) { + current = current.type; + } + return current; +} + function entityNameToString(name: ts.EntityName): string { if (ts.isIdentifier(name)) { return name.text; diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index e434f91c9..2b1f924da 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -147,6 +147,29 @@ describe("convertTypeNode (annotations)", () => { ]); }); + it("keeps generic structural aliases unspecialized and attaches use-site arguments", () => { + const { file } = lower(` + type Box = { value: T }; + function readNumber(value: Box): number { return value.value; } + function readString(value: Box): string { return value.value; } + `); + + const numberBox = methodByName(file, "readNumber").signature.parameters[0].type; + const stringBox = methodByName(file, "readString").signature.parameters[0].type; + expect(numberBox).toMatchObject({ _: "ClassType", typeParameters: [{ _: "NumberType" }] }); + expect(stringBox).toMatchObject({ _: "ClassType", typeParameters: [{ _: "StringType" }] }); + if (numberBox._ !== "ClassType" || stringBox._ !== "ClassType") { + throw new Error("expected structural class types"); + } + expect(numberBox.signature).toEqual(stringBox.signature); + + const structuralClass = file.classes.find( + (candidate) => candidate.signature.name === numberBox.signature.name, + ); + expect(structuralClass?.typeParameters).toEqual([{ _: "GenericType", name: "T" }]); + expect(structuralClass?.fields[0].signature.type).toEqual({ _: "GenericType", name: "T" }); + }); + it("does not inherit arguments on nested aliases without arguments", () => { const { file } = lower( "type Json = T | Json[];\nfunction parse(json: Json): void {}", From e7f43e23e54efbe650b9a0b3e3a3206979cf7abb Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 16:25:11 +0300 Subject: [PATCH 5/9] [ETS] Preserve wrapped generic structural aliases --- jacodb-ets/ts-frontend/src/types/convert.ts | 114 +++++++++++++------- jacodb-ets/ts-frontend/test/cli.spec.ts | 35 ++++++ jacodb-ets/ts-frontend/test/types.spec.ts | 36 +++++++ 3 files changed, 145 insertions(+), 40 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index d0e4da345..26309234a 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -63,6 +63,10 @@ const MAX_DEPTH = 8; export class TypeConverter { readonly structuralClasses: ClassDto[] = []; private readonly structuralClassByNode = new Map(); + private readonly structuralTypeParametersByNode = new Map< + ts.TypeNode, + readonly ts.TypeParameterDeclaration[] + >(); constructor( private readonly checker: ts.TypeChecker, @@ -221,20 +225,16 @@ export class TypeConverter { return this.materializeStructuralClass(node, members, depth, substitutions); } - /** Materialize a structural alias in its declaring file before any use-site specialization. */ + /** Materialize structural nodes in an alias declaration before any use-site specialization. */ materializeStructuralAlias(decl: ts.TypeAliasDeclaration): void { - const target = unwrapParenthesizedType(decl.type); - if (!ts.isTypeLiteralNode(target) && target.kind !== ts.SyntaxKind.ObjectKeyword) { - return; - } - const type = this.convertTypeNode(target); - if (type._ !== "ClassType") { - return; - } - const structuralClass = this.structuralClassByNode.get(target); - if (structuralClass !== undefined) { - structuralClass.typeParameters = this.convertTypeParameters(decl.typeParameters); - } + const visit = (node: ts.Node): void => { + if (ts.isTypeLiteralNode(node) || node.kind === ts.SyntaxKind.ObjectKeyword) { + this.convertTypeNode(node as ts.TypeNode); + return; + } + ts.forEachChild(node, visit); + }; + visit(decl.type); } private materializeStructuralClass( @@ -245,9 +245,10 @@ export class TypeConverter { ): ClassTypeDto { const existing = this.structuralClassByNode.get(node); if (existing !== undefined) { - return { _: "ClassType", signature: existing.signature }; + return this.structuralClassType(node, existing, substitutions); } + const typeParameters = this.structuralTypeParameters(node); const signature: ClassSignatureDto = { name: `%ST${node.getStart(node.getSourceFile())}`, declaringFile: this.fileSignatureFor(node.getSourceFile()), @@ -262,17 +263,23 @@ export class TypeConverter { fields: [], methods: [], }; + const convertedTypeParameters = this.convertTypeParameters(typeParameters); + if (convertedTypeParameters !== undefined) { + structuralClass.typeParameters = convertedTypeParameters; + } // Register the shell before converting fields so recursive aliases such as // `type Node = { next?: Node }` resolve back to the same structural class. this.structuralClassByNode.set(node, structuralClass); this.structuralClasses.push(structuralClass); + const definitionSubstitutions = new Map(substitutions); + typeParameters.forEach((parameter) => definitionSubstitutions.delete(parameter)); structuralClass.fields = members.map((member): FieldDto => ({ signature: { declaringClass: signature, name: memberName(member.name), - type: this.convertTypeNode(member.type, depth + 1, substitutions), + type: this.convertTypeNode(member.type, depth + 1, definitionSubstitutions), }, modifiers: modifiersOf(member), decorators: decoratorsOf(member), @@ -280,7 +287,47 @@ export class TypeConverter { exclamationToken: false, })); - return { _: "ClassType", signature }; + return this.structuralClassType(node, structuralClass, substitutions); + } + + private structuralClassType( + node: ts.TypeNode, + structuralClass: ClassDto, + substitutions?: ReadonlyMap, + ): ClassTypeDto { + const result: ClassTypeDto = { _: "ClassType", signature: structuralClass.signature }; + const typeParameters = this.structuralTypeParameters(node); + if (typeParameters.length > 0) { + result.typeParameters = typeParameters.map((parameter) => + substitutions?.get(parameter) ?? { _: "GenericType", name: parameter.name.text }, + ); + } + return result; + } + + private structuralTypeParameters(node: ts.TypeNode): readonly ts.TypeParameterDeclaration[] { + const cached = this.structuralTypeParametersByNode.get(node); + if (cached !== undefined) { + return cached; + } + + const result: ts.TypeParameterDeclaration[] = []; + const seen = new Set(); + const visit = (candidate: ts.Node): void => { + if (ts.isTypeReferenceNode(candidate)) { + const symbol = this.resolveSymbol(candidate.typeName); + const parameter = symbol?.declarations?.find(ts.isTypeParameterDeclaration); + if (parameter !== undefined && !isWithin(parameter, node) && !seen.has(parameter)) { + seen.add(parameter); + result.push(parameter); + } + } + ts.forEachChild(candidate, visit); + }; + visit(node); + result.sort((left, right) => left.pos - right.pos); + this.structuralTypeParametersByNode.set(node, result); + return result; } private convertLiteralTypeNode(node: ts.LiteralTypeNode): TypeDto { @@ -356,22 +403,6 @@ export class TypeConverter { aliasSubstitutions.delete(parameter); } }); - const structuralTarget = unwrapParenthesizedType(aliasDecl.type); - if (ts.isTypeLiteralNode(structuralTarget) || structuralTarget.kind === ts.SyntaxKind.ObjectKeyword) { - const definitionSubstitutions = new Map(substitutions); - aliasDecl.typeParameters?.forEach((parameter) => definitionSubstitutions.delete(parameter)); - const result = this.convertTypeNode(structuralTarget, depth + 1, definitionSubstitutions); - if (result._ === "ClassType") { - const structuralClass = this.structuralClassByNode.get(structuralTarget); - if (structuralClass !== undefined) { - structuralClass.typeParameters = this.convertTypeParameters(aliasDecl.typeParameters); - } - if (typeArgs !== undefined && typeArgs.length > 0) { - result.typeParameters = typeArgs; - } - } - return result; - } return this.convertTypeNode(aliasDecl.type, depth + 1, aliasSubstitutions); } } @@ -693,14 +724,6 @@ function unwrapTupleMember(node: ts.TypeNode): ts.TypeNode { return node; } -function unwrapParenthesizedType(node: ts.TypeNode): ts.TypeNode { - let current = node; - while (ts.isParenthesizedTypeNode(current)) { - current = current.type; - } - return current; -} - function entityNameToString(name: ts.EntityName): string { if (ts.isIdentifier(name)) { return name.text; @@ -708,6 +731,17 @@ function entityNameToString(name: ts.EntityName): string { return `${entityNameToString(name.left)}.${name.right.text}`; } +function isWithin(node: ts.Node, ancestor: ts.Node): boolean { + let current: ts.Node | undefined = node; + while (current !== undefined) { + if (current === ancestor) { + return true; + } + current = current.parent; + } + return false; +} + /** Class-like declaration of a symbol (class / interface / enum). */ function findClassLikeDeclaration( symbol: ts.Symbol, diff --git a/jacodb-ets/ts-frontend/test/cli.spec.ts b/jacodb-ets/ts-frontend/test/cli.spec.ts index 00aedd05e..6ed81feb2 100644 --- a/jacodb-ets/ts-frontend/test/cli.spec.ts +++ b/jacodb-ets/ts-frontend/test/cli.spec.ts @@ -155,6 +155,41 @@ describe("project mode", () => { expect(fromB.signature.parameters[0].type.signature).toEqual(structuralClasses[0].signature); }); + it("emits an imported generic structural type nested in an alias wrapper", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-generic-owner-")); + tempDirs.push(projectDir); + fs.writeFileSync( + path.join(projectDir, "a.ts"), + "type Identity = T; export type Box = Identity<{ value: T }>;", + ); + fs.writeFileSync( + path.join(projectDir, "b.ts"), + 'import { Box } from "./a"; export function read(value: Box): number { return value.value; }', + ); + + const outputDir = path.join(projectDir, "ir"); + expect(main(["--project", projectDir, outputDir])).toBe(0); + const declaringFile = JSON.parse(fs.readFileSync(path.join(outputDir, "a.ts.json"), "utf8")); + const importingFile = JSON.parse(fs.readFileSync(path.join(outputDir, "b.ts.json"), "utf8")); + const structuralClasses = [...declaringFile.classes, ...importingFile.classes].filter( + (candidate: { category?: number }) => candidate.category === ClassCategory.TYPE_LITERAL, + ); + + expect(structuralClasses).toHaveLength(1); + expect(structuralClasses[0]).toMatchObject({ + signature: { declaringFile: { fileName: "a.ts" } }, + typeParameters: [{ _: "GenericType", name: "T" }], + fields: [{ signature: { name: "value", type: { _: "GenericType", name: "T" } } }], + }); + const read = importingFile.classes[0].methods.find( + (method: { signature: { name: string } }) => method.signature.name === "read", + ); + expect(read.signature.parameters[0].type).toMatchObject({ + signature: structuralClasses[0].signature, + typeParameters: [{ _: "NumberType" }], + }); + }); + it("keeps an in-root filename beginning with two dots project-owned", () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-dotdot-file-")); tempDirs.push(projectDir); diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index 2b1f924da..0218d548f 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -170,6 +170,42 @@ describe("convertTypeNode (annotations)", () => { expect(structuralClass?.fields[0].signature.type).toEqual({ _: "GenericType", name: "T" }); }); + it("keeps generic structural aliases unspecialized through alias wrappers", () => { + const { file } = lower(` + type Identity = T; + type Box = Identity<{ second: U; first: T }>; + function readNumber(value: Box): number { return value.first; } + function readString(value: Box): string { return value.first; } + `); + + const numberBox = methodByName(file, "readNumber").signature.parameters[0].type; + const stringBox = methodByName(file, "readString").signature.parameters[0].type; + expect(numberBox).toMatchObject({ + _: "ClassType", + typeParameters: [{ _: "NumberType" }, { _: "StringType" }], + }); + expect(stringBox).toMatchObject({ + _: "ClassType", + typeParameters: [{ _: "StringType" }, { _: "NumberType" }], + }); + if (numberBox._ !== "ClassType" || stringBox._ !== "ClassType") { + throw new Error("expected structural class types"); + } + expect(numberBox.signature).toEqual(stringBox.signature); + + const structuralClass = file.classes.find( + (candidate) => candidate.signature.name === numberBox.signature.name, + ); + expect(structuralClass?.typeParameters).toEqual([ + { _: "GenericType", name: "T" }, + { _: "GenericType", name: "U" }, + ]); + expect(structuralClass?.fields.map((field) => field.signature.type)).toEqual([ + { _: "GenericType", name: "U" }, + { _: "GenericType", name: "T" }, + ]); + }); + it("does not inherit arguments on nested aliases without arguments", () => { const { file } = lower( "type Json = T | Json[];\nfunction parse(json: Json): void {}", From f0415b7cdc663be968d046f1df0513c43c9b4f4f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 17:20:29 +0300 Subject: [PATCH 6/9] [Build] Provision Node for JitPack --- jitpack.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 jitpack.yml diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 000000000..87e8dc2be --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,7 @@ +before_install: + - curl -fsSLO https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-glibc-217.tar.xz + - echo "1e96035030ea0d03e3c03221db47ff9576f9bbcf9c7f120485dca95ece70ea21 node-v22.20.0-linux-x64-glibc-217.tar.xz" | sha256sum --check + - tar -xJf node-v22.20.0-linux-x64-glibc-217.tar.xz + +install: + - PATH="$PWD/node-v22.20.0-linux-x64-glibc-217/bin:$PATH" ./gradlew clean -Pgroup=$GROUP -Pversion=$VERSION -xtest assemble publishToMavenLocal --console=plain From 4f864b60a2c908c4627fa2d4cf8c6345eff97e06 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 17:50:01 +0300 Subject: [PATCH 7/9] [ETS] Pin the Node.js toolchain --- jacodb-ets/ts-frontend/.nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacodb-ets/ts-frontend/.nvmrc b/jacodb-ets/ts-frontend/.nvmrc index 209e3ef4b..ccc4c6c7f 100644 --- a/jacodb-ets/ts-frontend/.nvmrc +++ b/jacodb-ets/ts-frontend/.nvmrc @@ -1 +1 @@ -20 +20.20.2 From a6b60d9236a9903d1721c76a8bec173f2fb46681 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 17:53:26 +0300 Subject: [PATCH 8/9] [Build] Harden the JitPack Node bootstrap --- gradle/jitpack/setup-node.sh | 45 ++++++++++++++++++++++++++++++++++++ jitpack.yml | 6 ++--- 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100755 gradle/jitpack/setup-node.sh diff --git a/gradle/jitpack/setup-node.sh b/gradle/jitpack/setup-node.sh new file mode 100755 index 000000000..e39202e0b --- /dev/null +++ b/gradle/jitpack/setup-node.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd) +node_version=$(tr -d '[:space:]' < "$repo_root/jacodb-ets/ts-frontend/.nvmrc") + +case "$node_version" in + 20.20.2) + node_sha256=bb8a5273607ebe712a27bb4f8870fb1257d09993b8d0812a4178a3feaa2effa4 + ;; + *) + echo "No checksum configured for Node.js $node_version" >&2 + exit 1 + ;; +esac + +if [ "$(uname -s)" != Linux ] || [ "$(uname -m)" != x86_64 ]; then + echo "The JitPack Node.js bootstrap supports Linux x86_64 only" >&2 + exit 1 +fi + +install_root=${JITPACK_NODE_INSTALL_ROOT:-"$repo_root/.jitpack"} +archive="node-v${node_version}-linux-x64-glibc-217.tar.xz" +archive_path="$install_root/$archive" +extracted_dir="$install_root/${archive%.tar.xz}" +node_dir="$install_root/node" +download_url="https://unofficial-builds.nodejs.org/download/release/v${node_version}/$archive" + +mkdir -p "$install_root" +curl --fail --location --show-error --silent \ + --retry 3 --retry-delay 1 \ + --output "$archive_path" \ + "$download_url" +printf '%s %s\n' "$node_sha256" "$archive_path" | sha256sum --check +tar -xJf "$archive_path" -C "$install_root" +ln -s "$extracted_dir" "$node_dir" + +if [ ! -x "$node_dir/bin/node" ] || [ ! -x "$node_dir/bin/npm" ]; then + echo "The downloaded Node.js toolchain is incomplete" >&2 + exit 1 +fi + +"$node_dir/bin/node" --version +"$node_dir/bin/npm" --version diff --git a/jitpack.yml b/jitpack.yml index 87e8dc2be..263406b6c 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,7 +1,5 @@ before_install: - - curl -fsSLO https://unofficial-builds.nodejs.org/download/release/v22.20.0/node-v22.20.0-linux-x64-glibc-217.tar.xz - - echo "1e96035030ea0d03e3c03221db47ff9576f9bbcf9c7f120485dca95ece70ea21 node-v22.20.0-linux-x64-glibc-217.tar.xz" | sha256sum --check - - tar -xJf node-v22.20.0-linux-x64-glibc-217.tar.xz + - ./gradle/jitpack/setup-node.sh install: - - PATH="$PWD/node-v22.20.0-linux-x64-glibc-217/bin:$PATH" ./gradlew clean -Pgroup=$GROUP -Pversion=$VERSION -xtest assemble publishToMavenLocal --console=plain + - PATH="$PWD/.jitpack/node/bin:$PATH" ./gradlew clean -Pgroup=$GROUP -Pversion=$VERSION -xtest assemble publishToMavenLocal --console=plain From feb74bcb07817491b935ff03697f616dd6709d9b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 22 Aug 2026 17:58:17 +0300 Subject: [PATCH 9/9] [Build] Verify npm with the bootstrapped Node --- gradle/jitpack/setup-node.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gradle/jitpack/setup-node.sh b/gradle/jitpack/setup-node.sh index e39202e0b..8245b37ec 100755 --- a/gradle/jitpack/setup-node.sh +++ b/gradle/jitpack/setup-node.sh @@ -41,5 +41,7 @@ if [ ! -x "$node_dir/bin/node" ] || [ ! -x "$node_dir/bin/npm" ]; then exit 1 fi -"$node_dir/bin/node" --version -"$node_dir/bin/npm" --version +PATH="$node_dir/bin:$PATH" +export PATH +node --version +npm --version