From b9735a93f4047ca74252f545b74e4bbbe974e935 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 23:05:05 +0300 Subject: [PATCH] [TS] Lower class expressions as constructor values (#388) --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 3 + .../main/kotlin/org/jacodb/ets/dto/Values.kt | 9 ++ .../main/kotlin/org/jacodb/ets/model/Expr.kt | 16 ++++ .../org/jacodb/ets/utils/GetOperands.kt | 4 + .../kotlin/org/jacodb/ets/utils/Handler.kt | 5 ++ .../ets/test/EtsTsClassExpressionTest.kt | 90 +++++++++++++++++++ jacodb-ets/ts-frontend/src/dto/values.ts | 6 ++ .../ts-frontend/src/lowering/classBuilder.ts | 8 +- .../ts-frontend/src/lowering/exprLowering.ts | 54 +++++++++++ .../ts-frontend/src/lowering/fileBuilder.ts | 1 + .../ts-frontend/src/lowering/methodBuilder.ts | 1 + jacodb-ets/ts-frontend/src/types/convert.ts | 22 ++++- jacodb-ets/ts-frontend/src/validate.ts | 3 + jacodb-ets/ts-frontend/test/classes.spec.ts | 60 +++++++++++++ 14 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsClassExpressionTest.kt diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt index 139b6bfdf..36a9f4d16 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt @@ -98,6 +98,7 @@ import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNeverType import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsNotEqExpr @@ -371,6 +372,8 @@ class EtsMethodBuilder( type = type.toEtsType(), ) + is NewClassExprDto -> EtsNewClassExpr(signature.toEtsClassSignature()) + is NewArrayExprDto -> EtsNewArrayExpr( elementType = elementType.toEtsType(), size = size.toEtsEntity(), diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt index 1fdc66cb3..884945a94 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt @@ -168,6 +168,15 @@ data class PropertyRefDto( override val type: TypeDto, ) : RefDto +@Serializable +@SerialName("NewClassExpr") +data class NewClassExprDto( + val signature: ClassSignatureDto, +) : ExprDto { + override val type: TypeDto + get() = ClassValueTypeDto(signature) +} + @Serializable @SerialName("NewArrayExpr") data class NewArrayExprDto( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt index 1a51d7945..19d205b4a 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt @@ -23,6 +23,10 @@ import org.jacodb.ets.toArrayType interface EtsExpr : EtsEntity { interface Visitor { fun visit(expr: EtsNewExpr): R + fun visit(expr: EtsNewClassExpr): R { + if (this is Default) return defaultVisit(expr) + error("Cannot handle ${expr::class.java.simpleName}: $expr") + } fun visit(expr: EtsSpreadExpansionExpr): R { if (this is Default) return defaultVisit(expr) error("Cannot handle ${expr::class.java.simpleName}: $expr") @@ -89,6 +93,7 @@ interface EtsExpr : EtsEntity { interface Default : Visitor { override fun visit(expr: EtsNewExpr): R = defaultVisit(expr) + override fun visit(expr: EtsNewClassExpr): R = defaultVisit(expr) override fun visit(expr: EtsSpreadExpansionExpr): R = defaultVisit(expr) override fun visit(expr: EtsToPropertyKeyExpr): R = defaultVisit(expr) override fun visit(expr: EtsNewArrayExpr): R = defaultVisit(expr) @@ -198,6 +203,17 @@ data class EtsToPropertyKeyExpr(val arg: EtsValue) : EtsExpr { override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) } +/** Evaluation of a class expression creates a fresh constructor for [signature]. */ +data class EtsNewClassExpr( + val signature: EtsClassSignature, +) : EtsExpr { + override val type: EtsType get() = EtsClassValueType(signature) + + override fun toString(): String = "class ${signature.name}" + + override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) +} + data class EtsNewArrayExpr( val elementType: EtsType, val size: EtsEntity, diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt index c5bb0dce4..21dcfd2cd 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsNotEqExpr @@ -319,4 +320,7 @@ private object EntityGetOperands : EtsEntity.Visitor> { override fun visit(expr: EtsSpreadExpansionExpr): Sequence = sequenceOf(expr.iterable) + + override fun visit(expr: EtsNewClassExpr): Sequence = + emptySequence() } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt index 0d64d6587..fe809f6a0 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt import org.jacodb.ets.model.EtsNotEqExpr @@ -494,4 +495,8 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor expr.iterable.accept(this) } + final override fun visit(expr: EtsNewClassExpr) { + handle(expr) + } + } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsClassExpressionTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsClassExpressionTest.kt new file mode 100644 index 000000000..c077f10c2 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsClassExpressionTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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. + */ + +package org.jacodb.ets.test + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.NewClassExprDto +import org.jacodb.ets.dto.NewExprDto +import org.jacodb.ets.dto.RawValueDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsNewClassExpr +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.getOperands +import org.junit.jupiter.api.Test +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class EtsTsClassExpressionTest { + private fun frontendRoundTrip(source: String): EtsFileDto { + val input = createTempDirectory("ts-calls-test").resolve("test.ts") + input.writeText(source) + + val output = generateEtsIR( + input, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + val dto = EtsFileDto.loadFromJson(output.readText()) + return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto)) + } + + private fun assignments(dto: EtsFileDto, method: String): List = + dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == method }.body!!.cfg.blocks + .flatMap { it.stmts }.filterIsInstance() + + @Test + fun `class expressions retain constructor identity field initialization and visitor support`() { + val dto = frontendRoundTrip( + """ + export function value(): number { + const Local = class { x = 1; }; + return new Local().x; + } + """.trimIndent(), + ) + val stmts = assignments(dto, "value") + val creation = stmts.single { it.right is NewClassExprDto }.right as NewClassExprDto + val clazz = dto.classes.single { it.signature == creation.signature } + val allocation = stmts.single { it.right is NewExprDto }.right as NewExprDto + + assertEquals(creation.signature, (allocation.classType as org.jacodb.ets.dto.ClassTypeDto).signature) + assertTrue(clazz.fields.any { it.signature.name == "x" }) + assertTrue(clazz.methods.any { it.signature.name == "%instInit" && it.body != null }) + assertTrue(clazz.methods.any { it.signature.name == "constructor" && it.body != null }) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "value" }.cfg.stmts.filterIsInstance() + val newClass = model.single { it.rhv is EtsNewClassExpr }.rhv as EtsNewClassExpr + assertTrue(newClass.getOperands().none()) + assertEquals(creation.signature.name, newClass.signature.name) + } + +} diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index 85ad03161..2d275f8c9 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -43,6 +43,7 @@ export type ImmediateDto = LocalDto | ConstantDto | ClassValueRefDto; export type ExprDto = | NewExprDto + | NewClassExprDto | SpreadExpansionExprDto | ToPropertyKeyExprDto | NewArrayExprDto @@ -114,6 +115,11 @@ export interface SpreadExpansionExprDto { expectedCount: number; } +export interface NewClassExprDto { + readonly _: "NewClassExpr"; + signature: ClassSignatureDto; +} + export interface NewArrayExprDto { readonly _: "NewArrayExpr"; elementType: TypeDto; diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index c6b625ff8..d141bd7fb 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -53,7 +53,7 @@ export class ClassBuilder { // Classes // ------------------------------------------------------------------ - buildClass(decl: ts.ClassDeclaration, nameOverride?: string): ClassDto { + buildClass(decl: ts.ClassLikeDeclaration, nameOverride?: string): ClassDto { const convertedSignature = this.ctx.converter.classSignatureOf(decl); const signature = nameOverride === undefined ? convertedSignature @@ -458,7 +458,7 @@ export class ClassBuilder { } private superClassInfo( - decl: ts.ClassDeclaration, + decl: ts.ClassLikeDeclaration, ): { signature: ClassSignatureDto; constructorDecl?: ts.ConstructorDeclaration } | undefined { const extended = decl.heritageClauses ?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword) @@ -627,7 +627,7 @@ function hasParameterPropertyModifier(p: ts.ParameterDeclaration): boolean { ); } -function superClassNameOf(decl: ts.ClassDeclaration): string | undefined { +function superClassNameOf(decl: ts.ClassLikeDeclaration): string | undefined { for (const clause of decl.heritageClauses ?? []) { if (clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length > 0) { return clause.types[0].expression.getText(); @@ -636,7 +636,7 @@ function superClassNameOf(decl: ts.ClassDeclaration): string | undefined { return undefined; } -function implementedInterfacesOf(decl: ts.ClassDeclaration): string[] { +function implementedInterfacesOf(decl: ts.ClassLikeDeclaration): string[] { for (const clause of decl.heritageClauses ?? []) { if (clause.token === ts.SyntaxKind.ImplementsKeyword) { return clause.types.map((t) => t.expression.getText()); diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 01f2a493c..3419ad33d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -294,6 +294,9 @@ export class ExprLowerer { if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { return this.lowerClosure(node); } + if (ts.isClassExpression(node)) { + return this.lowerClassExpression(node); + } if (ts.isObjectLiteralExpression(node)) { return this.lowerObjectLiteral(node); } @@ -1346,6 +1349,57 @@ export class ExprLowerer { // Closures / object literals // ------------------------------------------------------------------ + private lowerClassExpression(node: ts.ClassExpression): ValueDto { + if (node.heritageClauses?.length !== undefined && node.heritageClauses.length > 0) { + throw new LoweringError("class expression heritage is not represented in EtsIR"); + } + + if (node.name !== undefined || node.members.some((member) => + ts.isClassStaticBlockDeclaration(member) + || (ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static) !== 0 + || (member.name !== undefined && ts.isComputedPropertyName(member.name)))) { + throw new LoweringError("named, static, or computed class expression members are not represented in EtsIR"); + } + + let capturesLexicalValue = false; + const inspectCapture = (current: ts.Node): void => { + if (ts.isIdentifier(current)) { + const declarations = this.m.converter.symbolOf(current)?.declarations ?? []; + for (const declaration of declarations) { + if (!ts.isVariableDeclaration(declaration) && !ts.isParameter(declaration) + && !ts.isBindingElement(declaration) && !ts.isFunctionDeclaration(declaration) + && !ts.isClassDeclaration(declaration)) continue; + let ancestor: ts.Node | undefined = declaration; + let insideClass = false; + let methodLocal = false; + while (ancestor !== undefined) { + if (ancestor === node) insideClass = true; + if (ts.isFunctionLike(ancestor)) methodLocal = true; + ancestor = ancestor.parent; + } + if (!insideClass && methodLocal) capturesLexicalValue = true; + } + } + ts.forEachChild(current, inspectCapture); + }; + inspectCapture(node); + if (capturesLexicalValue) { + throw new LoweringError("class expression lexical environment is not represented in EtsIR"); + } + + const buildClass = this.m.ctx.buildClassExpression; + if (buildClass === undefined) { + throw new LoweringError("class expression in a context without a class builder"); + } + + const registry = this.m.ctx.anonymous; + const signature = this.m.converter.classSignatureOf(node); + if (!registry.classes.some((clazz) => clazz.signature.name === signature.name)) { + registry.classes.push(buildClass(node, signature.name)); + } + return { _: "NewClassExpr", signature }; + } + /** * Arrow function / function expression -> anonymous `%AM$` method * on the enclosing class; the use-site value is a Local of that name typed diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index d7d53520d..14a1777af 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -114,6 +114,7 @@ class FileBuilder { private readonly fileSignature: FileSignatureDto, ) { this.classBuilder = new ClassBuilder(ctx); + this.ctx.buildClassExpression = (node, name) => this.classBuilder.buildClass(node, name); } build(sourceFile: ts.SourceFile): EtsFileDto { diff --git a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts index 1e4b2708c..b83d80085 100644 --- a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts @@ -46,6 +46,7 @@ export interface LoweringContext { isDefaultLibrarySourceFile(sf: ts.SourceFile): boolean; diagnostics: Diagnostics; anonymous: AnonymousRegistry; + buildClassExpression?: (node: ts.ClassExpression, name: string) => ClassDto; /** File/namespace variables represented as static fields of the owning %dflt class. */ moduleFields: Map; } diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts index 063fc9cf4..ccd4a6d03 100644 --- a/jacodb-ets/ts-frontend/src/types/convert.ts +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -61,6 +61,7 @@ import { decoratorsOf, memberName, modifiersOf, resolvedSymbolOf } from "../lowe const MAX_DEPTH = 8; export class TypeConverter { + private readonly classExpressionSignatures = new WeakMap(); readonly structuralClasses: ClassDto[] = []; private readonly structuralClassByNode = new Map(); private readonly structuralTypeParametersByNode = new Map< @@ -79,6 +80,18 @@ export class TypeConverter { /** Class-like signature (class / interface / enum / struct) with its namespace chain. */ classSignatureOf(decl: ts.Declaration & { name?: ts.DeclarationName }): ClassSignatureDto { + if (ts.isClassExpression(decl)) { + const registered = this.classExpressionSignatures.get(decl); + if (registered !== undefined) return registered; + + const signature: ClassSignatureDto = { + name: `%ACExpr${decl.getStart()}`, + declaringFile: this.fileSignatureFor(decl.getSourceFile()), + }; + this.classExpressionSignatures.set(decl, signature); + return signature; + } + const name = decl.name !== undefined && ts.isIdentifier(decl.name) ? decl.name.text : ts.isClassDeclaration(decl) && (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Default) !== 0 @@ -599,7 +612,8 @@ export class TypeConverter { // Instances of project classes/interfaces/enums -> ClassType. const classDecl = symbol !== undefined ? findClassLikeDeclaration(symbol) : undefined; if (classDecl !== undefined && isProjectDeclaration(classDecl)) { - if (ts.isClassDeclaration(classDecl) && type.getConstructSignatures().length > 0) { + if ((ts.isClassDeclaration(classDecl) || ts.isClassExpression(classDecl)) + && type.getConstructSignatures().length > 0) { return { _: "ClassValueType", signature: this.classSignatureOf(classDecl) }; } const typeArgs = @@ -740,10 +754,10 @@ function isWithin(node: ts.Node, ancestor: ts.Node): boolean { /** Class-like declaration of a symbol (class / interface / enum). */ function findClassLikeDeclaration( symbol: ts.Symbol, -): (ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration) | undefined { +): (ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration | ts.EnumDeclaration) | undefined { return symbol.declarations?.find( - (d): d is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration => - ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d) || ts.isEnumDeclaration(d), + (d): d is ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration | ts.EnumDeclaration => + ts.isClassDeclaration(d) || ts.isClassExpression(d) || ts.isInterfaceDeclaration(d) || ts.isEnumDeclaration(d), ); } diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 3686f0afe..20f998b61 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -31,6 +31,7 @@ import { ValueDto } from "./dto/values"; const EXPR_KINDS = new Set([ "NewExpr", + "NewClassExpr", "SpreadExpansionExpr", "ToPropertyKeyExpr", "NewArrayExpr", @@ -398,6 +399,8 @@ export function valueOperands(value: ValueDto): ValueDto[] { return value.constructorValue !== undefined ? [value.constructorValue] : []; case "SpreadExpansionExpr": return [value.iterable]; + case "NewClassExpr": + return []; case "NewArrayExpr": return [value.size]; case "DeleteExpr": diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 12086eb99..728a9fcdd 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -33,6 +33,66 @@ function constructorCallOf(stmt: StmtDto): InstanceCallExprDto | undefined { } describe("class lowering", () => { + it("creates a fresh class expression value with constructor and field initializer", () => { + const source = ` + export function value(): number { + const Local = class { x = 1; }; + return new Local().x; + } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)) as EtsFileDto; + const method = methodOf(classByName(roundTrip, "%dflt"), "value"); + const stmts = singleBlockStmts(method); + const creation = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewClassExpr"); + + expect(creation).toMatchObject({ + right: { _: "NewClassExpr", signature: expect.objectContaining({ name: expect.stringContaining("%AC") }) }, + }); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr" + && stmt.right.constructorValue?._ === "Local")).toBe(true); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); + if (creation?._ !== "AssignStmt" || creation.right._ !== "NewClassExpr") return; + + const clazz = classByName(roundTrip, creation.right.signature.name); + const allocation = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"); + expect(allocation).toMatchObject({ right: { classType: { _: "ClassType", signature: clazz.signature } } }); + const instInit = methodOf(clazz, "%instInit"); + expect(clazz.fields).toContainEqual(expect.objectContaining({ signature: expect.objectContaining({ name: "x" }) })); + expect(singleBlockStmts(instInit)).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "InstanceFieldRef", field: expect.objectContaining({ name: "x" }) }), + right: expect.objectContaining({ _: "Constant", value: "1" }), + })); + expect(diagnostics.messages).toEqual([]); + + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + expect(new Function("exports", `${js}\nreturn exports.value();`)({})).toBe(1); + }); + + it("reports unrepresented class expression environments and static state explicitly", () => { + const { file, diagnostics } = lower(` + class Base {} + export function named(): unknown { return class Self {}; } + export function inherited(): unknown { return class extends Base {}; } + export function staticState(): unknown { return class { static x = 1; }; } + export function captured(value: number): unknown { return class { x = value; }; } + `); + + for (const name of ["named", "inherited", "staticState", "captured"]) { + const stmts = singleBlockStmts(methodOf(classByName(file, "%dflt"), name)); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewClassExpr")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(true); + } + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("class expression heritage"), + expect.stringContaining("class expression lexical environment"), + expect.stringContaining("named, static, or computed class expression"), + ])); + }); + it("preserves a declared class constructor across reads, assignments, and returns", () => { const source = ` class A { static marker = 7; }