From 0ded336d20df331571114a7d98c338e2ce61a8cd Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 22:55:07 +0300 Subject: [PATCH] [TS] Evaluate computed object-literal keys (#374) --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 24 ++- .../main/kotlin/org/jacodb/ets/dto/Stmts.kt | 8 + .../main/kotlin/org/jacodb/ets/dto/Values.kt | 16 ++ .../main/kotlin/org/jacodb/ets/model/Expr.kt | 15 ++ .../main/kotlin/org/jacodb/ets/model/Ref.kt | 13 ++ .../main/kotlin/org/jacodb/ets/model/Stmt.kt | 21 +++ .../main/kotlin/org/jacodb/ets/model/Value.kt | 5 + .../org/jacodb/ets/utils/GetOperands.kt | 12 ++ .../kotlin/org/jacodb/ets/utils/Handler.kt | 21 +++ .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 38 +++++ jacodb-ets/ts-frontend/src/dto/stmts.ts | 11 +- jacodb-ets/ts-frontend/src/dto/values.ts | 18 +- .../ts-frontend/src/lowering/astUtils.ts | 5 +- .../ts-frontend/src/lowering/exprLowering.ts | 91 +++++++--- jacodb-ets/ts-frontend/src/validate.ts | 10 +- jacodb-ets/ts-frontend/test/closures.spec.ts | 2 +- .../ts-frontend/test/export-default.spec.ts | 14 +- .../ts-frontend/test/object-computed.spec.ts | 101 +++++++++++ jacodb-ets/ts-frontend/test/object-runtime.ts | 161 ++++++++++++++++++ 19 files changed, 555 insertions(+), 31 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/object-computed.spec.ts create mode 100644 jacodb-ets/ts-frontend/test/object-runtime.ts 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 fba34efa4..139b6bfdf 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 @@ -17,6 +17,9 @@ package org.jacodb.ets.dto import mu.KotlinLogging +import org.jacodb.ets.model.EtsDefineDataPropertyStmt +import org.jacodb.ets.model.EtsPropertyRef +import org.jacodb.ets.model.EtsToPropertyKeyExpr import org.jacodb.ets.toArrayType import org.jacodb.ets.model.BasicBlock import org.jacodb.ets.model.EtsAddExpr @@ -222,7 +225,7 @@ class EtsMethodBuilder( } private fun ensureOneAddress(entity: EtsEntity): EtsValue { - if (entity is EtsExpr || entity is EtsFieldRef || entity is EtsArrayAccess) { + if (entity is EtsExpr || entity is EtsFieldRef || entity is EtsArrayAccess || entity is EtsPropertyRef) { return ensureLocal(entity) } else { if (entity !is EtsValue) { @@ -275,6 +278,13 @@ class EtsMethodBuilder( ) } + is DefineDataPropertyStmtDto -> EtsDefineDataPropertyStmt( + location = loc(), + target = (target as LocalDto).toEtsLocal(), + key = ensureOneAddress(key.toEtsEntity()), + value = ensureOneAddress(value.toEtsEntity()), + ) + is CallStmtDto -> { val expr = expr.toEtsEntity() if (expr !is EtsCallExpr) { @@ -351,6 +361,16 @@ class EtsMethodBuilder( expectedCount = expectedCount, ) + is ToPropertyKeyExprDto -> EtsToPropertyKeyExpr( + arg = ensureOneAddress(arg.toEtsEntity()), + ) + + is PropertyRefDto -> EtsPropertyRef( + instance = (instance as LocalDto).toEtsLocal(), + key = ensureOneAddress(key.toEtsEntity()), + type = type.toEtsType(), + ) + is NewArrayExprDto -> EtsNewArrayExpr( elementType = elementType.toEtsType(), size = size.toEtsEntity(), @@ -565,7 +585,7 @@ class EtsMethodBuilder( private fun EtsStmt.mayThrow(): Boolean = when (this) { is EtsAssignStmt -> lhv !is EtsLocal || rhv.mayThrow() - is EtsCallStmt, is EtsThrowStmt -> true + is EtsDefineDataPropertyStmt, is EtsCallStmt, is EtsThrowStmt -> true is EtsRawStmt -> true is EtsIfStmt, is EtsNopStmt, is EtsReturnStmt -> false else -> error("Unknown statement: ${this::class.java}") diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Stmts.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Stmts.kt index 8396e9c16..0d6101b1b 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Stmts.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Stmts.kt @@ -48,6 +48,14 @@ data class AssignStmtDto( val right: ValueDto, ) : StmtDto +@Serializable +@SerialName("DefineDataPropertyStmt") +data class DefineDataPropertyStmtDto( + val target: ValueDto, + val key: ValueDto, + val value: ValueDto, +) : StmtDto + @Serializable @SerialName("CallStmt") data class CallStmtDto( 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 020c90c19..1fdc66cb3 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 @@ -152,6 +152,22 @@ data class SpreadExpansionExprDto( override val type: TypeDto get() = UnknownTypeDto.toArrayType(dimensions = 1) } +@Serializable +@SerialName("ToPropertyKeyExpr") +data class ToPropertyKeyExprDto( + val arg: ValueDto, +) : ExprDto { + override val type: TypeDto get() = UnknownTypeDto +} + +@Serializable +@SerialName("PropertyRef") +data class PropertyRefDto( + val instance: ValueDto, // Local; polymorphic discriminator is required. + val key: ValueDto, + override val type: TypeDto, +) : RefDto + @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 085138395..1a51d7945 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 @@ -27,6 +27,10 @@ interface EtsExpr : EtsEntity { if (this is Default) return defaultVisit(expr) error("Cannot handle ${expr::class.java.simpleName}: $expr") } + fun visit(expr: EtsToPropertyKeyExpr): R { + if (this is Default) return defaultVisit(expr) + error("Cannot handle ${expr::class.java.simpleName}: $expr") + } fun visit(expr: EtsNewArrayExpr): R fun visit(expr: EtsCastExpr): R fun visit(expr: EtsInstanceOfExpr): R @@ -86,6 +90,7 @@ interface EtsExpr : EtsEntity { interface Default : Visitor { override fun visit(expr: EtsNewExpr): 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) override fun visit(expr: EtsCastExpr): R = defaultVisit(expr) override fun visit(expr: EtsInstanceOfExpr): R = defaultVisit(expr) @@ -180,6 +185,16 @@ data class EtsSpreadExpansionExpr( override fun toString(): String = "expandSpread($iterable, $expectedCount)" + + override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) +} + +/** ECMAScript ToPropertyKey (ToPrimitive with string hint, then string or symbol). */ +data class EtsToPropertyKeyExpr(val arg: EtsValue) : EtsExpr { + override val type: EtsType get() = EtsUnknownType + + override fun toString(): String = "toPropertyKey($arg)" + override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Ref.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Ref.kt index ebd747e07..3c59122aa 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Ref.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Ref.kt @@ -71,6 +71,19 @@ data class EtsArrayAccess( } } +/** ECMAScript property Get/Set, including prototypes and getter/setter invocation. + * [key] is the result of ToPropertyKey and must not be converted a second time. + */ +data class EtsPropertyRef( + val instance: EtsLocal, + val key: EtsValue, + override val type: EtsType, +) : EtsRef, EtsLValue { + override fun toString(): String = "$instance[$key]" + + override fun accept(visitor: EtsValue.Visitor): R = visitor.visit(this) +} + interface EtsFieldRef : EtsRef, EtsLValue, CommonFieldRef { override val instance: EtsLocal? val field: EtsFieldSignature diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Stmt.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Stmt.kt index 529904ecc..9cd0c7d5d 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Stmt.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Stmt.kt @@ -28,6 +28,10 @@ interface EtsStmt : CommonInst { interface Visitor { fun visit(stmt: EtsNopStmt): R fun visit(stmt: EtsAssignStmt): R + fun visit(stmt: EtsDefineDataPropertyStmt): R { + if (this is Default) return defaultVisit(stmt) + error("Cannot handle ${stmt::class.java.simpleName}: $stmt") + } fun visit(stmt: EtsReturnStmt): R fun visit(stmt: EtsThrowStmt): R fun visit(stmt: EtsIfStmt): R @@ -43,6 +47,7 @@ interface EtsStmt : CommonInst { interface Default : Visitor { override fun visit(stmt: EtsNopStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsAssignStmt): R = defaultVisit(stmt) + override fun visit(stmt: EtsDefineDataPropertyStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsReturnStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsThrowStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsIfStmt): R = defaultVisit(stmt) @@ -94,6 +99,22 @@ data class EtsAssignStmt( } } +/** + * CreateDataPropertyOrThrow: an own writable, enumerable, configurable data property. + * Replaces an existing accessor descriptor without invoking its setter. + * [key] has already been converted by ToPropertyKey. + */ +data class EtsDefineDataPropertyStmt( + override val location: EtsStmtLocation, + val target: EtsLocal, + val key: EtsValue, + val value: EtsValue, +) : EtsStmt { + override fun toString(): String = "defineDataProperty($target, $key, $value)" + + override fun accept(visitor: EtsStmt.Visitor): R = visitor.visit(this) +} + interface EtsTerminatingStmt : EtsStmt data class EtsReturnStmt( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Value.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Value.kt index 3514a4607..a1e518822 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Value.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Value.kt @@ -38,6 +38,10 @@ interface EtsValue : EtsEntity, CommonValue { fun visit(value: EtsThis): R fun visit(value: EtsParameterRef): R fun visit(value: EtsArrayAccess): R + fun visit(value: EtsPropertyRef): R { + if (this is Default) return defaultVisit(value) + error("Cannot handle ${value::class.java.simpleName}: $value") + } fun visit(value: EtsInstanceFieldRef): R fun visit(value: EtsStaticFieldRef): R @@ -59,6 +63,7 @@ interface EtsValue : EtsEntity, CommonValue { override fun visit(value: EtsThis): R = defaultVisit(value) override fun visit(value: EtsParameterRef): R = defaultVisit(value) override fun visit(value: EtsArrayAccess): R = defaultVisit(value) + override fun visit(value: EtsPropertyRef): R = defaultVisit(value) override fun visit(value: EtsInstanceFieldRef): R = defaultVisit(value) override fun visit(value: EtsStaticFieldRef): R = defaultVisit(value) 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 4ffd6e686..c5bb0dce4 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 @@ -32,6 +32,7 @@ import org.jacodb.ets.model.EtsCastExpr import org.jacodb.ets.model.EtsCaughtExceptionRef import org.jacodb.ets.model.EtsClosureFieldRef import org.jacodb.ets.model.EtsConstant +import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsDeleteExpr import org.jacodb.ets.model.EtsDivExpr import org.jacodb.ets.model.EtsEntity @@ -66,6 +67,7 @@ import org.jacodb.ets.model.EtsPostDecExpr import org.jacodb.ets.model.EtsPostIncExpr import org.jacodb.ets.model.EtsPreDecExpr import org.jacodb.ets.model.EtsPreIncExpr +import org.jacodb.ets.model.EtsPropertyRef import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsRawEntity import org.jacodb.ets.model.EtsRawStmt @@ -81,6 +83,7 @@ import org.jacodb.ets.model.EtsStringConstant import org.jacodb.ets.model.EtsSubExpr import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsThrowStmt +import org.jacodb.ets.model.EtsToPropertyKeyExpr import org.jacodb.ets.model.EtsTypeOfExpr import org.jacodb.ets.model.EtsUnaryPlusExpr import org.jacodb.ets.model.EtsUndefinedConstant @@ -104,6 +107,9 @@ private object StmtGetOperands : EtsStmt.Visitor> { override fun visit(stmt: EtsAssignStmt): Sequence = sequenceOf(stmt.rhv) + override fun visit(stmt: EtsDefineDataPropertyStmt): Sequence = + sequenceOf(stmt.target, stmt.key, stmt.value) + override fun visit(stmt: EtsCallStmt): Sequence = sequenceOf(stmt.expr) @@ -173,6 +179,12 @@ private object EntityGetOperands : EtsEntity.Visitor> { override fun visit(expr: EtsNewExpr): Sequence = listOfNotNull(expr.constructorValue).asSequence() + override fun visit(expr: EtsToPropertyKeyExpr): Sequence = + sequenceOf(expr.arg) + + override fun visit(value: EtsPropertyRef): Sequence = + sequenceOf(value.instance, value.key) + override fun visit(expr: EtsNewArrayExpr): Sequence = sequenceOf(expr.size) 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 1632cf9f5..0d64d6587 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 @@ -32,6 +32,7 @@ import org.jacodb.ets.model.EtsCastExpr import org.jacodb.ets.model.EtsCaughtExceptionRef import org.jacodb.ets.model.EtsClosureFieldRef import org.jacodb.ets.model.EtsConstant +import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsDeleteExpr import org.jacodb.ets.model.EtsDivExpr import org.jacodb.ets.model.EtsEntity @@ -66,6 +67,7 @@ import org.jacodb.ets.model.EtsPostDecExpr import org.jacodb.ets.model.EtsPostIncExpr import org.jacodb.ets.model.EtsPreDecExpr import org.jacodb.ets.model.EtsPreIncExpr +import org.jacodb.ets.model.EtsPropertyRef import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsRawEntity import org.jacodb.ets.model.EtsRawStmt @@ -81,6 +83,7 @@ import org.jacodb.ets.model.EtsStringConstant import org.jacodb.ets.model.EtsSubExpr import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsThrowStmt +import org.jacodb.ets.model.EtsToPropertyKeyExpr import org.jacodb.ets.model.EtsTypeOfExpr import org.jacodb.ets.model.EtsUnaryPlusExpr import org.jacodb.ets.model.EtsUndefinedConstant @@ -128,6 +131,13 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor stmt.rhv.accept(this) } + final override fun visit(stmt: EtsDefineDataPropertyStmt) { + handle(stmt) + stmt.target.accept(this) + stmt.key.accept(this) + stmt.value.accept(this) + } + final override fun visit(stmt: EtsCallStmt) { handle(stmt) stmt.expr.accept(this) @@ -226,6 +236,17 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor expr.constructorValue?.accept(this) } + final override fun visit(expr: EtsToPropertyKeyExpr) { + handle(expr) + expr.arg.accept(this) + } + + final override fun visit(value: EtsPropertyRef) { + handle(value) + value.instance.accept(this) + value.key.accept(this) + } + final override fun visit(expr: EtsNewArrayExpr) { handle(expr) expr.size.accept(this) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt index 7960a7962..668ab6e3e 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -27,6 +27,7 @@ import org.jacodb.ets.dto.ClassTypeDto import org.jacodb.ets.dto.ClassValueRefDto import org.jacodb.ets.dto.ClassValueTypeDto import org.jacodb.ets.dto.ConstantDto +import org.jacodb.ets.dto.DefineDataPropertyStmtDto import org.jacodb.ets.dto.EtsFileDto import org.jacodb.ets.dto.IfStmtDto import org.jacodb.ets.dto.InstanceOfExprDto @@ -35,6 +36,7 @@ import org.jacodb.ets.dto.NewArrayExprDto import org.jacodb.ets.dto.NewExprDto import org.jacodb.ets.dto.NumberTypeDto import org.jacodb.ets.dto.Ops +import org.jacodb.ets.dto.PropertyRefDto import org.jacodb.ets.dto.RawStmtDto import org.jacodb.ets.dto.RawValueDto import org.jacodb.ets.dto.RelationOperationDto @@ -43,6 +45,7 @@ import org.jacodb.ets.dto.StringTypeDto import org.jacodb.ets.dto.StaticCallExprDto import org.jacodb.ets.dto.StaticFieldRefDto import org.jacodb.ets.dto.ThrowStmtDto +import org.jacodb.ets.dto.ToPropertyKeyExprDto import org.jacodb.ets.dto.UnaryOperationDto import org.jacodb.ets.dto.UnknownTypeDto import org.jacodb.ets.dto.ValueDto @@ -55,18 +58,21 @@ import org.jacodb.ets.model.EtsCallStmt import org.jacodb.ets.model.EtsClassValueRef import org.jacodb.ets.model.EtsClassValueType import org.jacodb.ets.model.EtsClosureFieldRef +import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsEqExpr import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsInstanceOfExpr import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsPropertyRef import org.jacodb.ets.model.EtsRawEntity import org.jacodb.ets.model.EtsRawStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNumberConstant import org.jacodb.ets.model.EtsThrowStmt +import org.jacodb.ets.model.EtsToPropertyKeyExpr import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME import org.jacodb.ets.utils.EtsIrProvider @@ -110,6 +116,38 @@ class EtsTsFrontendTest { } } + @Test + fun `computed object keys survive the frontend JSON round trip`() { + val frontendDto = runFrontend( + """ + export function read(key: string): number { + const object = { [key]: 1 }; + return object[key]; + } + """.trimIndent(), + ) + + val serialized = Json { serializersModule = dtoModule }.encodeToString(frontendDto) + val roundTripped = EtsFileDto.loadFromJson(serialized) + val dtoStmts = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "read" } + .body!!.cfg.blocks.flatMap { it.stmts } + val assignments = dtoStmts.filterIsInstance() + + assertEquals(1, dtoStmts.filterIsInstance().size) + assertTrue(assignments.any { it.right is PropertyRefDto }) + assertEquals(2, assignments.count { it.right is ToPropertyKeyExprDto }) + assertTrue(assignments.none { it.right is RawValueDto }) + + val scene = EtsScene(listOf(roundTripped.toEtsFile())) + val method = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "read" } + val converted = method.cfg.stmts.filterIsInstance() + assertEquals(1, method.cfg.stmts.filterIsInstance().size) + assertTrue(converted.any { it.rhv is EtsPropertyRef }) + assertEquals(2, converted.count { it.rhv is EtsToPropertyKeyExpr }) + } + @Test fun `default export expression executes during module initialization and survives JSON conversion`() { val dto = runFrontend( diff --git a/jacodb-ets/ts-frontend/src/dto/stmts.ts b/jacodb-ets/ts-frontend/src/dto/stmts.ts index d1b585c0e..220fe0852 100644 --- a/jacodb-ets/ts-frontend/src/dto/stmts.ts +++ b/jacodb-ets/ts-frontend/src/dto/stmts.ts @@ -22,7 +22,7 @@ * Unknown discriminators fall back to RawStmtDto on the Kotlin side. */ -import { CallExprDto, LValueDto, ValueDto } from "./values"; +import { CallExprDto, ImmediateDto, LocalDto, LValueDto, ValueDto } from "./values"; /** * Closed union of the known statement kinds. @@ -32,6 +32,7 @@ import { CallExprDto, LValueDto, ValueDto } from "./values"; export type StmtDto = | NopStmtDto | AssignStmtDto + | DefineDataPropertyStmtDto | CallStmtDto | ReturnVoidStmtDto | ReturnStmtDto @@ -54,6 +55,14 @@ export interface AssignStmtDto { right: ValueDto; } +/** Define an own enumerable configurable writable data property, without invoking an existing setter. */ +export interface DefineDataPropertyStmtDto { + readonly _: "DefineDataPropertyStmt"; + target: LocalDto; + key: ImmediateDto; + value: ImmediateDto; +} + export interface CallStmtDto { readonly _: "CallStmt"; expr: CallExprDto; diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index be76c050e..85ad03161 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -44,6 +44,7 @@ export type ImmediateDto = LocalDto | ConstantDto | ClassValueRefDto; export type ExprDto = | NewExprDto | SpreadExpansionExprDto + | ToPropertyKeyExprDto | NewArrayExprDto | DeleteExprDto | AwaitExprDto @@ -67,11 +68,12 @@ export type RefDto = | GlobalRefDto | ClosureFieldRefDto | ArrayRefDto + | PropertyRefDto | InstanceFieldRefDto | StaticFieldRefDto; /** LValue kinds accepted by Kotlin Convert as the LHS of AssignStmt. */ -export type LValueDto = LocalDto | ClosureFieldRefDto | ArrayRefDto | InstanceFieldRefDto | StaticFieldRefDto; +export type LValueDto = LocalDto | ClosureFieldRefDto | ArrayRefDto | PropertyRefDto | InstanceFieldRefDto | StaticFieldRefDto; /** Fallback for constructs we cannot model; Kotlin deserializes any unknown kind into RawValueDto. */ export interface RawValueDto { @@ -151,6 +153,20 @@ export interface CastExprDto { type: TypeDto; } +/** ECMAScript ToPropertyKey; result is a string or symbol, evaluated at this statement. */ +export interface ToPropertyKeyExprDto { + readonly _: "ToPropertyKeyExpr"; + arg: ImmediateDto; +} + +/** Generic property Get/Set, with an already converted runtime key. */ +export interface PropertyRefDto { + readonly _: "PropertyRef"; + instance: LocalDto; + key: ImmediateDto; + type: TypeDto; +} + export interface UnopExprDto { readonly _: "UnopExpr"; op: UnaryOp; diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 59fcf23fa..abde9cddd 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -229,12 +229,11 @@ export function usesModuleLexicalThis(expression: ts.Expression): boolean { return visit(expression); } -/** Object-literal lowering currently emits `%computed` without evaluating the key. */ +/** Computed method and accessor names still lack a runtime definition. */ export function hasUnsupportedComputedObjectKey(expression: ts.Expression): boolean { const visit = (node: ts.Node): boolean => { if (ts.isObjectLiteralExpression(node) && node.properties.some((property) => - (ts.isPropertyAssignment(property) - || ts.isMethodDeclaration(property) + (ts.isMethodDeclaration(property) || ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property)) && ts.isComputedPropertyName(property.name), diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 0610698c4..5dbdb138d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -191,6 +191,11 @@ export class ExprLowerer { return this.materialize(value, this.safeTypeOf(node)); } + /** Convert at the source key's evaluation point, before value/default side effects. */ + lowerPropertyKey(node: ts.Expression): ImmediateDto { + return this.materialize({ _: "ToPropertyKeyExpr", arg: this.lowerToImmediate(node) }, UNKNOWN_TYPE); + } + /** Hoist a value into a fresh temp: `%t := value`. */ materialize(value: ValueDto, type: TypeDto = UNKNOWN_TYPE): LocalDto { const temp = this.m.newTemp(type); @@ -427,6 +432,18 @@ export class ExprLowerer { if (chain !== undefined) { return this.lowerOptionalChain(node, chain); } + const receiverType = this.m.checker.getTypeAtLocation(node.expression); + if (!this.m.checker.isArrayType(receiverType) + && !this.m.checker.isTupleType(receiverType)) { + const instance = this.snapshotToLocal(node.expression); + return { + _: "PropertyRef", + instance, + key: this.lowerPropertyKey(node.argumentExpression), + type: this.safeTypeOf(node), + }; + } + const array = this.snapshotValueIfReassigned( this.lowerToImmediate(node.expression), node.expression, @@ -557,6 +574,15 @@ export class ExprLowerer { }, }; } + const receiverType = this.m.checker.getTypeAtLocation(node.expression); + if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) { + return { + _: "PropertyRef", + instance, + key: this.lowerPropertyKey(node.argumentExpression), + type: this.safeTypeOf(node), + }; + } return { _: "ArrayRef", array: instance, @@ -588,6 +614,11 @@ export class ExprLowerer { } if (ts.isElementAccessExpression(node)) { const ref = this.lowerElementAccess(node); + if (ref._ === "PropertyRef") { + return this.mayReassign(node.expression, laterExpression) + ? { ...ref, instance: this.m.snapshotToLocal(ref.instance, ref.instance.type) } + : ref; + } if (ref._ === "ArrayRef") { return { ...ref, @@ -1400,22 +1431,41 @@ export class ExprLowerer { }; const classType: ClassTypeDto = { _: "ClassType", signature }; - // Evaluate property values BEFORE instantiation (source evaluation order). - const stores: { name: string; type: TypeDto; value: ValueDto }[] = []; const fields: FieldDto[] = []; const methods: MethodDto[] = []; + const temp = this.m.newTemp(classType); + this.m.cfg.emit({ _: "AssignStmt", left: temp, right: { _: "NewExpr", classType } }); + for (const property of node.properties) { if (ts.isPropertyAssignment(property)) { - const propName = memberName(property.name); const propType = this.safeTypeOf(property.initializer); - fields.push(objectField(signature, propName, propType)); - stores.push({ name: propName, type: propType, value: this.lowerToImmediate(property.initializer) }); + if (ts.isComputedPropertyName(property.name)) { + const key = this.lowerPropertyKey(property.name.expression); + const value = this.lowerToImmediate(property.initializer); + this.m.cfg.emit({ + _: "DefineDataPropertyStmt", + target: temp, + key, + value, + }); + } else { + const propName = memberName(property.name); + if (propName === "__proto__") { + throw new LoweringError("object literal prototype setter is not represented in EtsIR"); + } + fields.push(objectField(signature, propName, propType)); + const value = this.lowerToImmediate(property.initializer); + this.emitObjectPropertyStore(temp, propName, value); + } } else if (ts.isShorthandPropertyAssignment(property)) { const propName = property.name.text; const value = this.lowerToImmediate(property.name); fields.push(objectField(signature, propName, value.type)); - stores.push({ name: propName, type: value.type, value }); + this.emitObjectPropertyStore(temp, propName, value); } else if (ts.isMethodDeclaration(property)) { + if (ts.isComputedPropertyName(property.name)) { + throw new LoweringError("computed object method name is not represented in EtsIR"); + } const methodName = memberName(property.name); const { parameters, prologueParams } = buildParameters(this.m.ctx, property); const methodSignature: MethodSignatureDto = { @@ -1436,7 +1486,7 @@ export class ExprLowerer { body: methodContext.build(), }); } else { - // spread / accessors / computed names degrade the whole literal + // Spread and accessors remain separately unsupported. throw new LoweringError(`object literal member: ${syntaxKindName(property.kind)}`); } } @@ -1452,22 +1502,22 @@ export class ExprLowerer { methods, }); - const temp = this.m.newTemp(classType); - this.m.cfg.emit({ _: "AssignStmt", left: temp, right: { _: "NewExpr", classType } }); - for (const store of stores) { - this.m.cfg.emit({ - _: "AssignStmt", - left: { - _: "InstanceFieldRef", - instance: temp, - field: { declaringClass: signature, name: store.name, type: store.type }, - }, - right: store.value, - }); - } return temp; } + private emitObjectPropertyStore( + instance: LocalDto, + name: string, + value: ImmediateDto, + ): void { + this.m.cfg.emit({ + _: "DefineDataPropertyStmt", + target: instance, + key: constant(name, STRING_TYPE), + value, + }); + } + // ------------------------------------------------------------------ // Optional chaining // ------------------------------------------------------------------ @@ -1668,6 +1718,7 @@ function lvalueType(target: LValueDto): TypeDto { case "Local": case "ClosureFieldRef": case "ArrayRef": + case "PropertyRef": return target.type; case "InstanceFieldRef": case "StaticFieldRef": diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 7a92e26a7..3686f0afe 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -32,6 +32,7 @@ import { ValueDto } from "./dto/values"; const EXPR_KINDS = new Set([ "NewExpr", "SpreadExpansionExpr", + "ToPropertyKeyExpr", "NewArrayExpr", "DeleteExpr", "AwaitExpr", @@ -54,6 +55,7 @@ const REF_KINDS = new Set([ "GlobalRef", "ClosureFieldRef", "ArrayRef", + "PropertyRef", "InstanceFieldRef", "StaticFieldRef", ]); @@ -65,7 +67,7 @@ const KNOWN_VALUE_KINDS = new Set([...EXPR_KINDS, ...REF_KINDS, ...IMMEDIATE_KIN const CALL_EXPR_KINDS = new Set(["InstanceCallExpr", "StaticCallExpr", "PtrCallExpr"]); /** LValue kinds accepted by Kotlin Convert as AssignStmt.left after stripping a legacy cast. */ -const LVALUE_KINDS = new Set(["Local", "ClosureFieldRef", "ArrayRef", "InstanceFieldRef", "StaticFieldRef"]); +const LVALUE_KINDS = new Set(["Local", "ClosureFieldRef", "ArrayRef", "PropertyRef", "InstanceFieldRef", "StaticFieldRef"]); export function validateEtsFile(file: EtsFileDto): string[] { const errors: string[] = []; @@ -281,6 +283,8 @@ function stmtOperands(stmt: StmtDto): ValueDto[] { switch (stmt._) { case "AssignStmt": return [stmt.left, stmt.right]; + case "DefineDataPropertyStmt": + return [stmt.target, stmt.key, stmt.value]; case "CallStmt": return [stmt.expr]; case "ReturnStmt": @@ -348,6 +352,7 @@ function validateValue( err(`${ctx}: InstanceCallExpr.instance has kind '${value.instance._}', must be Local`); } break; + case "PropertyRef": case "InstanceFieldRef": if (value.instance._ !== "Local") { err(`${ctx}: InstanceFieldRef.instance has kind '${value.instance._}', must be Local`); @@ -400,6 +405,7 @@ export function valueOperands(value: ValueDto): ValueDto[] { case "YieldExpr": case "TypeOfExpr": case "CastExpr": + case "ToPropertyKeyExpr": case "UnopExpr": return [value.arg]; case "InstanceOfExpr": @@ -421,6 +427,8 @@ export function valueOperands(value: ValueDto): ValueDto[] { return []; case "ArrayRef": return [value.array, value.index]; + case "PropertyRef": + return [value.instance, value.key]; case "InstanceFieldRef": return [value.instance]; default: diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts index 5243c22f2..c27231304 100644 --- a/jacodb-ets/ts-frontend/test/closures.spec.ts +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -281,7 +281,7 @@ describe("object literals", () => { ); expect(newExpr).toBeDefined(); const fieldStores = stmts.filter( - (s) => s._ === "AssignStmt" && s.left._ === "InstanceFieldRef", + (s) => s._ === "DefineDataPropertyStmt", ); expect(fieldStores).toHaveLength(2); expect(stmts).toContainEqual(expect.objectContaining({ diff --git a/jacodb-ets/ts-frontend/test/export-default.spec.ts b/jacodb-ets/ts-frontend/test/export-default.spec.ts index b4ea111f5..e62480dd7 100644 --- a/jacodb-ets/ts-frontend/test/export-default.spec.ts +++ b/jacodb-ets/ts-frontend/test/export-default.spec.ts @@ -108,9 +108,7 @@ describe("export default expressions", () => { it.each([ "({ [this]() { return 1; } })", - "({ [this]: 1 })", "({ [key]() { return 1; } })", - "({ [key]: 1 })", ])("rejects a computed object key in %s", (expression) => { const { file, diagnostics } = lower(` const key = "name"; @@ -125,6 +123,18 @@ describe("export default expressions", () => { expect(defaultClass.fields.some((field) => field.signature.name === "default")).toBe(false); }); + it("exports an object with a computed data property", () => { + const { file, diagnostics } = lower(` + const key = "name"; + export default { [key]: 1 }; + `); + const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(stmts.some((stmt) => stmt._ === "DefineDataPropertyStmt")).toBe(true); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); + expect(diagnostics.messages).toEqual([]); + }); + it("matches the ES-module evaluation of a computed method name", () => { const source = `export default ({ [this]() { return 1; } });`; const moduleUrl = `data:text/javascript,${encodeURIComponent(source)}`; diff --git a/jacodb-ets/ts-frontend/test/object-computed.spec.ts b/jacodb-ets/ts-frontend/test/object-computed.spec.ts new file mode 100644 index 000000000..8d31d4057 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-computed.spec.ts @@ -0,0 +1,101 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { DefineDataPropertyStmtDto } from "../src/dto/stmts"; +import { compile, lower, methodByName } from "./util"; +import { executeObjectIr } from "./object-runtime"; + +function evaluate(source: string, expression: string): unknown { + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + return runInNewContext(`${javascript}\n${expression}`, { exports: {} }); +} + +describe("computed object keys", () => { + it("writes a computed literal property using its once-evaluated key", () => { + const source = ` + export function read(key: string): number { + return ({ [key]: 1 })[key]; + } + `; + const compiled = compile(source); + expect(compiled.program.getSemanticDiagnostics(compiled.sourceFile)).toEqual([]); + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "read").body!.cfg.blocks.flatMap((block) => block.stmts); + const store = stmts.find((stmt): stmt is DefineDataPropertyStmtDto => + stmt._ === "DefineDataPropertyStmt", + ); + + expect(store?.key).toMatchObject({ _: "Local", name: expect.stringMatching(/^%/) }); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + right: { _: "ToPropertyKeyExpr", arg: { _: "Local", name: "key", type: { _: "StringType" } } }, + })); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true); + expect(file.classes.flatMap((clazz) => clazz.fields).some((field) => field.signature.name === "%computed")) + .toBe(false); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.read("x")`)).toBe(1); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read", ["x"])).toBe(1); + }); + + it("snapshots a computed key before the property value changes its binding", () => { + const source = ` + let key = "x"; + let calls = 0; + function takeKey(): string { calls++; return key; } + export function read(): number { + const object = { [takeKey()]: (key = "y", 7) }; + return object.x + calls; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "read").body!.cfg.blocks.flatMap((block) => block.stmts); + const keyCallIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "takeKey"); + const storeIndex = stmts.findIndex((stmt) => stmt._ === "DefineDataPropertyStmt"); + const keyStore = stmts[storeIndex] as DefineDataPropertyStmtDto; + + expect(keyCallIndex).toBeGreaterThanOrEqual(0); + expect(storeIndex).toBeGreaterThan(keyCallIndex); + expect(keyStore.key).toMatchObject({ _: "Local" }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.read()`)).toBe(8); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read")).toBe(8); + }); + + it("converts an object key before evaluating the property value", () => { + const source = ` + export function read(key: any, state: { phase: string }): number { + const object = { [key]: (state.phase = "after", 7) }; + return object.x; + } + `; + const { file, diagnostics } = lower(source); + const state = { phase: "before" }; + let conversions = 0; + const key = { toString() { conversions++; return state.phase === "before" ? "x" : "y"; } }; + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read", [key, state])).toBe(7); + expect(conversions).toBe(1); + expect(evaluate(source, `(() => { + const state = { phase: "before" }; + const key = { toString() { return state.phase === "before" ? "x" : "y"; } }; + return exports.read(key, state); + })()`)).toBe(7); + }); + it("preserves the receiver before evaluating a computed access key", () => { + const source = `export function read(key: string): number { + let input = { x: 3 }; + return input[(input = { x: 9 }, key)]; + }`; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read", ["x"])).toBe(3); + expect(evaluate(source, `exports.read("x")`)).toBe(3); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts new file mode 100644 index 000000000..16fb5e2c1 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -0,0 +1,161 @@ +import { EtsFileDto, MethodDto } from "../src/dto/model"; +import { LexicalEnvTypeDto } from "../src/dto/types"; +import { ClosureFieldRefDto } from "../src/dto/values"; + +/** + * A deliberately small concrete consumer for object-lowering regressions. + * Executes serialized EtsIR using JavaScript property operations; this is not + * the USVM interpreter and rejects every operation outside the tested subset. + */ +export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] = []): unknown { + const methods = file.classes.flatMap((clazz) => clazz.methods); + const moduleValues = new Map(); + const lookup = (signature: any): MethodDto => { + const method = methods.find((candidate) => candidate.signature.name === signature.name + && candidate.signature.declaringClass.name === signature.declaringClass.name); + if (method === undefined) throw new Error(`unknown test method ${signature.name}`); + return method; + }; + const defineData = (target: object, key: PropertyKey, value: unknown): void => { + Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); + }; + const invoke = (method: MethodDto, supplied: unknown[], receiver: unknown): unknown => { + if (method.body === undefined) throw new Error(`bodyless test method ${method.signature.name}`); + const locals = new Map(); + const environment = (type: LexicalEnvTypeDto): object => { + const slots = new Set(type.closures.map((capture) => capture.name)); + const checkSlot = (key: PropertyKey): string => { + const name = String(key); + if (!slots.has(name)) throw new Error(`undeclared test capture ${name}`); + return name; + }; + + return new Proxy({}, { + get: (_target, key) => locals.get(checkSlot(key)), + set: (_target, key, value) => { + locals.set(checkSlot(key), value); + return true; + }, + }); + }; + const captureEnvironment = (reference: ClosureFieldRefDto): Record => { + const type = reference.base.type; + if (type._ !== "LexicalEnvType" || !type.closures.some((capture) => capture.name === reference.fieldName)) { + throw new Error(`undeclared test capture ${reference.fieldName}`); + } + + const captured = locals.get(reference.base.name); + if (captured === null || typeof captured !== "object") { + throw new Error(`missing test closure environment ${reference.base.name}`); + } + return captured as Record; + }; + const read = (value: any): any => { + switch (value._) { + case "Local": { + if (!locals.has(value.name) && value.type._ === "FunctionType") { + const lifted = lookup(value.type.signature); + const environmentType = lifted.signature.parameters[0]?.type; + const lexicalEnvironment = environmentType?._ === "LexicalEnvType" + ? environment(environmentType) + : undefined; + + locals.set(value.name, function (this: unknown, ...callArgs: unknown[]) { + const supplied = lexicalEnvironment === undefined ? callArgs : [lexicalEnvironment, ...callArgs]; + return invoke(lifted, supplied, this); + }); + } + return locals.get(value.name); + } + case "Constant": + switch (value.type._) { + case "StringType": return value.value; + case "NumberType": return Number(value.value); + case "BooleanType": return value.value === "true"; + case "NullType": return null; + case "UndefinedType": return undefined; + default: throw new Error(`unknown constant ${value.type._}`); + } + case "ParameterRef": return supplied[value.index]; + case "ThisRef": return receiver; + case "NewExpr": return {}; + case "StaticFieldRef": return moduleValues.get(value.field.name); + case "ClosureFieldRef": return captureEnvironment(value)[value.fieldName]; + case "InstanceFieldRef": return read(value.instance)[value.field.name]; + case "PropertyRef": return read(value.instance)[read(value.key)]; + case "ArrayRef": return read(value.array)[read(value.index)]; + case "ToPropertyKeyExpr": return Reflect.ownKeys({ [read(value.arg)]: 0 })[0]; + case "StaticCallExpr": return invoke(lookup(value.method), value.args.map(read), undefined); + case "InstanceCallExpr": { + const object = read(value.instance); + const member = object[value.method.name]; + return typeof member === "function" + ? member.apply(object, value.args.map(read)) + : invoke(lookup(value.method), value.args.map(read), object); + } + case "PtrCallExpr": return Reflect.apply( + read(value.ptr), + undefined, + value.args.map(read), + ); + case "CastExpr": return read(value.arg); + case "UnopExpr": + if (value.op === "+") return +read(value.arg); + if (value.op === "++") return Number(read(value.arg)) + 1; + throw new Error(`unknown unary operator ${value.op}`); + case "ConditionExpr": + switch (value.op) { + case "===": return read(value.left) === read(value.right); + case "!==": return read(value.left) !== read(value.right); + case "!=": return read(value.left) != read(value.right); + case "==": return read(value.left) == read(value.right); + default: throw new Error(`unknown relation ${value.op}`); + } + case "BinopExpr": + switch (value.op) { + case "+": return read(value.left) + read(value.right); + case "*": return read(value.left) * read(value.right); + default: throw new Error(`unknown binary operator ${value.op}`); + } + default: throw new Error(`unsupported object test value ${value._}`); + } + }; + const assign = (left: any, value: unknown): void => { + switch (left._) { + case "Local": locals.set(left.name, value); return; + case "StaticFieldRef": moduleValues.set(left.field.name, value); return; + case "ClosureFieldRef": captureEnvironment(left)[left.fieldName] = value; return; + case "InstanceFieldRef": read(left.instance)[left.field.name] = value; return; + case "PropertyRef": read(left.instance)[read(left.key)] = value; return; + default: throw new Error(`unsupported object test assignment ${left._}`); + } + }; + const blocks = method.body.cfg.blocks; + let block = blocks[0]; + for (let steps = 0; block !== undefined && steps < 1000; steps++) { + let next = block.successors[0]; + for (const stmt of block.stmts) { + switch (stmt._) { + case "AssignStmt": assign(stmt.left, read(stmt.right)); break; + case "DefineDataPropertyStmt": defineData(read(stmt.target), read(stmt.key), read(stmt.value)); break; + case "IfStmt": next = block.successors[read(stmt.condition) ? 1 : 0]; break; + case "ReturnStmt": return read(stmt.arg); + case "ReturnVoidStmt": return undefined; + case "ThrowStmt": throw read(stmt.arg); + case "CallStmt": read(stmt.expr); break; + case "NopStmt": break; + default: throw new Error(`unsupported object test statement ${stmt._}`); + } + } + block = blocks[next]; + } + if (block !== undefined) throw new Error("object test exceeded step limit"); + return undefined; + }; + + const module = methods.find((method) => method.signature.name === "%dflt"); + if (module !== undefined) invoke(module, [], undefined); + const method = methods.find((method) => method.signature.name === name); + if (method === undefined) throw new Error(`missing object test method ${name}`); + return invoke(method, args, undefined); +}