diff --git a/gradle/jitpack/setup-node.sh b/gradle/jitpack/setup-node.sh new file mode 100755 index 000000000..8245b37ec --- /dev/null +++ b/gradle/jitpack/setup-node.sh @@ -0,0 +1,47 @@ +#!/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 + +PATH="$node_dir/bin:$PATH" +export PATH +node --version +npm --version 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 diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 2b853f0b9..4d9ddf4e2 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); + 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. @@ -296,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 576feb797..26309234a 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,19 @@ 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(); + private readonly structuralTypeParametersByNode = new Map< + ts.TypeNode, + readonly ts.TypeParameterDeclaration[] + >(); + constructor( private readonly checker: ts.TypeChecker, private readonly fileSignatureFor: (sf: ts.SourceFile) => FileSignatureDto, @@ -141,6 +150,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: @@ -181,6 +192,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 +205,131 @@ 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); + } + + return this.materializeStructuralClass(node, members, depth, substitutions); + } + + /** Materialize structural nodes in an alias declaration before any use-site specialization. */ + materializeStructuralAlias(decl: ts.TypeAliasDeclaration): void { + 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( + node: ts.TypeNode, + members: readonly ts.PropertySignature[], + depth: number, + substitutions?: ReadonlyMap, + ): ClassTypeDto { + const existing = this.structuralClassByNode.get(node); + if (existing !== undefined) { + 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()), + }; + const structuralClass: ClassDto = { + signature, + modifiers: 0, + decorators: [], + category: ClassCategory.TYPE_LITERAL, + superClassName: "", + implementedInterfaceNames: [], + 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, definitionSubstitutions), + }, + modifiers: modifiersOf(member), + decorators: decoratorsOf(member), + questionToken: member.questionToken !== undefined, + exclamationToken: false, + })); + + 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 { const literal = node.literal; if (literal.kind === ts.SyntaxKind.NullKeyword) { @@ -592,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 105b6244a..6ed81feb2 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,69 @@ 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("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 9c3726959..0218d548f 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"; @@ -146,6 +147,65 @@ 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("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 {}", @@ -177,6 +237,50 @@ 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("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({ @@ -203,7 +307,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" }); }); diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 000000000..263406b6c --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,5 @@ +before_install: + - ./gradle/jitpack/setup-node.sh + +install: + - PATH="$PWD/.jitpack/node/bin:$PATH" ./gradlew clean -Pgroup=$GROUP -Pversion=$VERSION -xtest assemble publishToMavenLocal --console=plain