From 6603e20a88fd63a621afe0596aca0196c59b4345 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 23:00:58 +0300 Subject: [PATCH 1/2] [TS] Copy object spread properties in evaluation order (#369) --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 15 ++- .../main/kotlin/org/jacodb/ets/dto/Stmts.kt | 10 ++ .../main/kotlin/org/jacodb/ets/model/Stmt.kt | 25 +++++ .../org/jacodb/ets/utils/GetOperands.kt | 4 + .../kotlin/org/jacodb/ets/utils/Handler.kt | 8 ++ .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 33 +++++++ jacodb-ets/ts-frontend/src/dto/stmts.ts | 10 ++ .../ts-frontend/src/lowering/exprLowering.ts | 11 ++- jacodb-ets/ts-frontend/src/validate.ts | 2 + .../ts-frontend/test/object-copy.spec.ts | 99 +++++++++++++++++++ jacodb-ets/ts-frontend/test/object-runtime.ts | 17 ++++ 11 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/object-copy.spec.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 7d3d8fe72..4e15bd793 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 @@ -28,6 +28,7 @@ import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr import org.jacodb.ets.model.EtsBitNotExpr @@ -287,6 +288,18 @@ class EtsMethodBuilder( value = ensureOneAddress(value.toEtsEntity()), ) + is CopyDataPropertiesStmtDto -> { + val targetLocal = target as? LocalDto + ?: error("CopyDataPropertiesStmt target must be LocalDto") + EtsCopyDataPropertiesStmt( + location = loc(), + target = targetLocal.toEtsLocal(), + source = ensureOneAddress(source.toEtsEntity()), + excludedKeys = excludedKeys.map { ensureOneAddress(it.toEtsEntity()) }, + throwOnNullishSource = throwOnNullishSource, + ) + } + is CallStmtDto -> { val expr = expr.toEtsEntity() if (expr !is EtsCallExpr) { @@ -596,7 +609,7 @@ class EtsMethodBuilder( private fun EtsStmt.mayThrow(): Boolean = when (this) { is EtsAssignStmt -> lhv !is EtsLocal || rhv.mayThrow() - is EtsDefineDataPropertyStmt, is EtsCallStmt, is EtsThrowStmt -> true + is EtsDefineDataPropertyStmt, is EtsCopyDataPropertiesStmt, 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 0d6101b1b..54feed332 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 @@ -56,6 +56,16 @@ data class DefineDataPropertyStmtDto( val value: ValueDto, ) : StmtDto +/** ECMAScript CopyDataProperties, including ordered own enumerable keys. */ +@Serializable +@SerialName("CopyDataPropertiesStmt") +data class CopyDataPropertiesStmtDto( + val target: ValueDto, // Local; polymorphic JSON includes its discriminator. + val source: ValueDto, + val excludedKeys: List, + val throwOnNullishSource: Boolean, +) : StmtDto + @Serializable @SerialName("CallStmt") data class CallStmtDto( 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 9cd0c7d5d..f79db52ba 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 @@ -32,6 +32,10 @@ interface EtsStmt : CommonInst { if (this is Default) return defaultVisit(stmt) error("Cannot handle ${stmt::class.java.simpleName}: $stmt") } + fun visit(stmt: EtsCopyDataPropertiesStmt): 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 @@ -48,6 +52,7 @@ interface EtsStmt : CommonInst { 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: EtsCopyDataPropertiesStmt): 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) @@ -115,6 +120,26 @@ data class EtsDefineDataPropertyStmt( override fun accept(visitor: EtsStmt.Visitor): R = visitor.visit(this) } +/** + * Copy each own enumerable property of [source] to [target] in ECMAScript key + * enumeration order, reading values through Get and creating data properties. + * Keys in [excludedKeys] have already been evaluated and converted to property + * keys before this statement. A nullish source is ignored for spread and rejected for + * object rest according to [throwOnNullishSource]. + */ +data class EtsCopyDataPropertiesStmt( + override val location: EtsStmtLocation, + val target: EtsLocal, + val source: EtsValue, + val excludedKeys: List, + val throwOnNullishSource: Boolean, +) : EtsStmt { + override fun toString(): String = + "copyDataProperties($target, $source, ${excludedKeys.joinToString(prefix = "[", postfix = "]")})" + + 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/utils/GetOperands.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt index af3c64a41..7337761d4 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 @@ -20,6 +20,7 @@ import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr import org.jacodb.ets.model.EtsBitNotExpr @@ -112,6 +113,9 @@ private object StmtGetOperands : EtsStmt.Visitor> { override fun visit(stmt: EtsDefineDataPropertyStmt): Sequence = sequenceOf(stmt.target, stmt.key, stmt.value) + override fun visit(stmt: EtsCopyDataPropertiesStmt): Sequence = + sequenceOf(stmt.target, stmt.source) + stmt.excludedKeys.asSequence() + override fun visit(stmt: EtsCallStmt): Sequence = sequenceOf(stmt.expr) 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 2a371d4e5..ed1e49a2c 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 @@ -20,6 +20,7 @@ import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr import org.jacodb.ets.model.EtsBitNotExpr @@ -140,6 +141,13 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor stmt.value.accept(this) } + final override fun visit(stmt: EtsCopyDataPropertiesStmt) { + handle(stmt) + stmt.target.accept(this) + stmt.source.accept(this) + stmt.excludedKeys.forEach { it.accept(this) } + } + final override fun visit(stmt: EtsCallStmt) { handle(stmt) stmt.expr.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 668ab6e3e..63766f759 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 @@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json import org.jacodb.ets.dto.ArrayRefDto import org.jacodb.ets.dto.ArrayTypeDto import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.CopyDataPropertiesStmtDto import org.jacodb.ets.dto.BooleanTypeDto import org.jacodb.ets.dto.CaughtExceptionRefDto import org.jacodb.ets.dto.ClassTypeDto @@ -53,6 +54,7 @@ import org.jacodb.ets.dto.dtoModule import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsCaughtExceptionRef import org.jacodb.ets.model.EtsCallStmt import org.jacodb.ets.model.EtsClassValueRef @@ -86,6 +88,7 @@ import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -116,6 +119,36 @@ class EtsTsFrontendTest { } } + @Test + fun `object spread preserves ordered copy operations through JSON conversion`() { + val frontendDto = runFrontend( + """ + export function copy(input: { x: number; y: number }): number { + const object = { ...input, y: 7 }; + return object.y + object.x; + } + """.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 == "copy" } + .body!!.cfg.blocks.flatMap { it.stmts } + val copies = dtoStmts.filterIsInstance() + + assertEquals(1, copies.size) + assertFalse(copies[0].throwOnNullishSource) + assertTrue(copies[0].excludedKeys.isEmpty()) + + val scene = EtsScene(listOf(roundTripped.toEtsFile())) + val method = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "copy" } + val converted = method.cfg.stmts.filterIsInstance() + assertEquals(1, converted.size) + assertTrue(converted[0].excludedKeys.isEmpty()) + } + @Test fun `computed object keys survive the frontend JSON round trip`() { val frontendDto = runFrontend( diff --git a/jacodb-ets/ts-frontend/src/dto/stmts.ts b/jacodb-ets/ts-frontend/src/dto/stmts.ts index 220fe0852..e59fd0406 100644 --- a/jacodb-ets/ts-frontend/src/dto/stmts.ts +++ b/jacodb-ets/ts-frontend/src/dto/stmts.ts @@ -32,6 +32,7 @@ import { CallExprDto, ImmediateDto, LocalDto, LValueDto, ValueDto } from "./valu export type StmtDto = | NopStmtDto | AssignStmtDto + | CopyDataPropertiesStmtDto | DefineDataPropertyStmtDto | CallStmtDto | ReturnVoidStmtDto @@ -63,6 +64,15 @@ export interface DefineDataPropertyStmtDto { value: ImmediateDto; } +/** ECMAScript CopyDataProperties: own enumerable keys in source enumeration order. */ +export interface CopyDataPropertiesStmtDto { + readonly _: "CopyDataPropertiesStmt"; + target: LocalDto; + source: ImmediateDto; + excludedKeys: ImmediateDto[]; + throwOnNullishSource: boolean; +} + export interface CallStmtDto { readonly _: "CallStmt"; expr: CallExprDto; diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 23dd286aa..5dbb88a8a 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1610,6 +1610,15 @@ export class ExprLowerer { const value = this.lowerToImmediate(property.name); fields.push(objectField(signature, propName, value.type)); this.emitObjectPropertyStore(temp, propName, value); + } else if (ts.isSpreadAssignment(property)) { + const source = this.lowerToImmediate(property.expression); + this.m.cfg.emit({ + _: "CopyDataPropertiesStmt", + target: temp, + source, + excludedKeys: [], + throwOnNullishSource: false, + }); } else if (ts.isMethodDeclaration(property)) { if (ts.isComputedPropertyName(property.name)) { throw new LoweringError("computed object method name is not represented in EtsIR"); @@ -1634,7 +1643,7 @@ export class ExprLowerer { body: methodContext.build(), }); } else { - // Spread and accessors remain separately unsupported. + // Accessors remain separately unsupported. throw new LoweringError(`object literal member: ${syntaxKindName(property.kind)}`); } } diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 88015ceff..cbe1bf32f 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -287,6 +287,8 @@ function stmtOperands(stmt: StmtDto): ValueDto[] { return [stmt.left, stmt.right]; case "DefineDataPropertyStmt": return [stmt.target, stmt.key, stmt.value]; + case "CopyDataPropertiesStmt": + return [stmt.target, stmt.source, ...stmt.excludedKeys]; case "CallStmt": return [stmt.expr]; case "ReturnStmt": diff --git a/jacodb-ets/ts-frontend/test/object-copy.spec.ts b/jacodb-ets/ts-frontend/test/object-copy.spec.ts new file mode 100644 index 000000000..3fb60fe90 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-copy.spec.ts @@ -0,0 +1,99 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +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("object property copying", () => { + it("copies spread properties before later explicit overrides", () => { + const source = ` + export function read(input: { x: number; y?: number }): number { + const object = { ...input, y: 1 }; + return object.x + object.y; + } + `; + 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 copyIndex = stmts.findIndex((stmt) => stmt._ === "CopyDataPropertiesStmt"); + const overrideIndex = stmts.findIndex((stmt) => stmt._ === "DefineDataPropertyStmt" + && stmt.key._ === "Constant" && stmt.key.value === "y"); + + expect(copyIndex).toBeGreaterThanOrEqual(0); + expect(stmts[copyIndex]).toMatchObject({ + _: "CopyDataPropertiesStmt", + source: { _: "Local", name: "input" }, + excludedKeys: [], + throwOnNullishSource: false, + }); + expect(overrideIndex).toBeGreaterThan(copyIndex); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.read({ x: 3, y: 9 })`)).toBe(4); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read", [{ x: 3, y: 9 }])).toBe(4); + }); + + it("copies own enumerable properties when spread encounters a getter", () => { + const source = ` + export function read(input: { x: number }): number { + return { ...input, x: 7 }.x; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "read").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(stmts.filter((stmt) => stmt._ === "CopyDataPropertiesStmt")).toHaveLength(1); + let reads = 0; + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read", [{ get x() { reads++; return 3; } }])).toBe(7); + expect(reads).toBe(1); + expect(evaluate(source, `(() => { + let reads = 0; + const result = exports.read({ get x() { reads++; return 3; } }); + return result * 10 + reads; + })()`)).toBe(71); + }); + + it("copies enumerable symbols and excludes inherited and non-enumerable properties", () => { + const source = `export function copy(input: any): any { return { ...input }; }`; + const { file, diagnostics } = lower(source); + const symbol = Symbol("own"); + const shared = { value: 1 }; + const input = Object.create({ inherited: 3 }); + input.x = shared; + input[symbol] = 4; + Object.defineProperty(input, "hidden", { value: 9 }); + + const result = executeObjectIr(JSON.parse(JSON.stringify(file)), "copy", [input]) as any; + + expect(diagnostics.messages).toEqual([]); + expect(Reflect.ownKeys(result)).toEqual(["x", symbol]); + expect(result.x).toBe(shared); + expect(result[symbol]).toBe(4); + expect(evaluate(source, `(() => { + const symbol = Symbol("own"); + const input = Object.create({ inherited: 3 }); + input.x = {}; input[symbol] = 4; + Object.defineProperty(input, "hidden", { value: 9 }); + const result = exports.copy(input); + return Reflect.ownKeys(result).length * 10 + result[symbol] + (result.x === input.x ? 1 : 0); + })()`)).toBe(25); + }); + it("skips nullish spread sources", () => { + const source = `export function spread(input: any): number { return { ...input, x: 7 }.x; }`; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "spread", [null])).toBe(7); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "spread", [undefined])).toBe(7); + expect(evaluate(source, `exports.spread(null)`)).toBe(7); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts index 16fb5e2c1..5d7ead032 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -138,6 +138,23 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] 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 "CopyDataPropertiesStmt": { + const source = read(stmt.source); + if (source === null || source === undefined) { + if (stmt.throwOnNullishSource) throw new TypeError("nullish object rest"); + break; + } + const object = Object(source); + const target = read(stmt.target); + const excluded = stmt.excludedKeys.map(read); + for (const key of Reflect.ownKeys(object)) { + if (!excluded.includes(key) && Object.getOwnPropertyDescriptor(object, key)?.enumerable) { + defineData(target, key, object[key]); + } + } + break; + } + case "IfStmt": next = block.successors[read(stmt.condition) ? 1 : 0]; break; case "ReturnStmt": return read(stmt.arg); case "ReturnVoidStmt": return undefined; From 5fe4f8587eafdc7449fc9176ab1b6b1ac9813b6c Mon Sep 17 00:00:00 2001 From: Alexey Menshutin Date: Sat, 10 Oct 2026 20:01:35 +0300 Subject: [PATCH 2/2] [TS] Preserve object getters and getter-selected calls (#381) (#410) --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 10 +- .../main/kotlin/org/jacodb/ets/dto/Stmts.kt | 8 + .../main/kotlin/org/jacodb/ets/model/Stmt.kt | 22 +++ .../org/jacodb/ets/utils/GetOperands.kt | 4 + .../kotlin/org/jacodb/ets/utils/Handler.kt | 8 + .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 35 ++++ jacodb-ets/ts-frontend/src/dto/stmts.ts | 9 + .../ts-frontend/src/lowering/astUtils.ts | 6 +- .../ts-frontend/src/lowering/exprLowering.ts | 140 ++++++++++---- jacodb-ets/ts-frontend/src/validate.ts | 2 + .../test/evaluation-effects.spec.ts | 167 +++++++++++++++++ jacodb-ets/ts-frontend/test/lowering.spec.ts | 7 +- .../ts-frontend/test/object-accessors.spec.ts | 102 +++++++++++ .../ts-frontend/test/object-runtime.spec.ts | 113 ++++++++++++ jacodb-ets/ts-frontend/test/object-runtime.ts | 16 +- .../test/optional-callee-wrappers.spec.ts | 172 ++++++++++++++++++ .../ts-frontend/test/state-and-order.spec.ts | 41 +++-- jacodb-ets/ts-frontend/test/try.spec.ts | 14 +- 18 files changed, 813 insertions(+), 63 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/evaluation-effects.spec.ts create mode 100644 jacodb-ets/ts-frontend/test/object-accessors.spec.ts create mode 100644 jacodb-ets/ts-frontend/test/object-runtime.spec.ts create mode 100644 jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.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 4e15bd793..1104b3508 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 @@ -28,6 +28,7 @@ import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr @@ -300,6 +301,13 @@ class EtsMethodBuilder( ) } + is DefineAccessorStmtDto -> EtsDefineAccessorStmt( + location = loc(), + target = (target as LocalDto).toEtsLocal(), + key = ensureOneAddress(key.toEtsEntity()), + getter = ensureOneAddress(getter.toEtsEntity()), + ) + is CallStmtDto -> { val expr = expr.toEtsEntity() if (expr !is EtsCallExpr) { @@ -609,7 +617,7 @@ class EtsMethodBuilder( private fun EtsStmt.mayThrow(): Boolean = when (this) { is EtsAssignStmt -> lhv !is EtsLocal || rhv.mayThrow() - is EtsDefineDataPropertyStmt, is EtsCopyDataPropertiesStmt, is EtsCallStmt, is EtsThrowStmt -> true + is EtsDefineDataPropertyStmt, is EtsCopyDataPropertiesStmt, is EtsDefineAccessorStmt, 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 54feed332..58b06de97 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 @@ -66,6 +66,14 @@ data class CopyDataPropertiesStmtDto( val throwOnNullishSource: Boolean, ) : StmtDto +@Serializable +@SerialName("DefineAccessorStmt") +data class DefineAccessorStmtDto( + val target: ValueDto, + val key: ValueDto, + val getter: ValueDto, +) : StmtDto + @Serializable @SerialName("CallStmt") data class CallStmtDto( 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 f79db52ba..6bb27d954 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 @@ -36,6 +36,10 @@ interface EtsStmt : CommonInst { if (this is Default) return defaultVisit(stmt) error("Cannot handle ${stmt::class.java.simpleName}: $stmt") } + fun visit(stmt: EtsDefineAccessorStmt): 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 @@ -53,6 +57,7 @@ interface EtsStmt : CommonInst { override fun visit(stmt: EtsAssignStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsDefineDataPropertyStmt): R = defaultVisit(stmt) override fun visit(stmt: EtsCopyDataPropertiesStmt): R = defaultVisit(stmt) + override fun visit(stmt: EtsDefineAccessorStmt): 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) @@ -140,6 +145,23 @@ data class EtsCopyDataPropertiesStmt( override fun accept(visitor: EtsStmt.Visitor): R = visitor.visit(this) } +/** + * Define an own enumerable, configurable getter descriptor. Property Get invokes + * [getter] with [target] as its dynamic receiver. Creating it does not invoke the body; + * an existing setter component is retained, while a data descriptor is replaced. + * [key] has already been converted by ToPropertyKey. + */ +data class EtsDefineAccessorStmt( + override val location: EtsStmtLocation, + val target: EtsLocal, + val key: EtsValue, + val getter: EtsValue, +) : EtsStmt { + override fun toString(): String = "defineAccessor($target, $key, $getter)" + + 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/utils/GetOperands.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt index 7337761d4..dc829d02a 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 @@ -20,6 +20,7 @@ import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr @@ -116,6 +117,9 @@ private object StmtGetOperands : EtsStmt.Visitor> { override fun visit(stmt: EtsCopyDataPropertiesStmt): Sequence = sequenceOf(stmt.target, stmt.source) + stmt.excludedKeys.asSequence() + override fun visit(stmt: EtsDefineAccessorStmt): Sequence = + sequenceOf(stmt.target, stmt.key, stmt.getter) + override fun visit(stmt: EtsCallStmt): Sequence = sequenceOf(stmt.expr) 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 ed1e49a2c..4e1a9681a 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 @@ -20,6 +20,7 @@ import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsAndExpr import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsAwaitExpr import org.jacodb.ets.model.EtsBitAndExpr @@ -148,6 +149,13 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor stmt.excludedKeys.forEach { it.accept(this) } } + final override fun visit(stmt: EtsDefineAccessorStmt) { + handle(stmt) + stmt.target.accept(this) + stmt.key.accept(this) + stmt.getter.accept(this) + } + final override fun visit(stmt: EtsCallStmt) { handle(stmt) stmt.expr.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 63766f759..c53f4cbb9 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 @@ -21,6 +21,9 @@ import kotlinx.serialization.json.Json import org.jacodb.ets.dto.ArrayRefDto import org.jacodb.ets.dto.ArrayTypeDto import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.LexicalEnvTypeDto +import org.jacodb.ets.dto.FunctionTypeDto +import org.jacodb.ets.dto.DefineAccessorStmtDto import org.jacodb.ets.dto.CopyDataPropertiesStmtDto import org.jacodb.ets.dto.BooleanTypeDto import org.jacodb.ets.dto.CaughtExceptionRefDto @@ -54,6 +57,8 @@ import org.jacodb.ets.dto.dtoModule import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsStringConstant +import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsCopyDataPropertiesStmt import org.jacodb.ets.model.EtsCaughtExceptionRef import org.jacodb.ets.model.EtsCallStmt @@ -119,6 +124,36 @@ class EtsTsFrontendTest { } } + @Test + fun `object getter descriptor and lifted body survive JSON conversion`() { + val frontendDto = runFrontend( + """ + export function value(seed: number): number { + const object = { y: 2, get x() { return seed + this.y; } }; + return object.x; + } + """.trimIndent(), + ) + + val serialized = Json { serializersModule = dtoModule }.encodeToString(frontendDto) + val roundTripped = EtsFileDto.loadFromJson(serialized) + val dtoMethods = roundTripped.classes.flatMap { it.methods } + val descriptor = dtoMethods.single { it.signature.name == "value" } + .body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance().single() + val getterType = (descriptor.getter as LocalDto).type as FunctionTypeDto + val getterBody = dtoMethods.single { it.signature.name == getterType.signature.name }.body!! + + assertEquals("x", (descriptor.key as ConstantDto).value) + assertTrue(getterBody.cfg.blocks.flatMap { it.stmts }.any { it is ReturnStmtDto }) + assertTrue(getterBody.locals.any { it.type is LexicalEnvTypeDto }) + + val scene = EtsScene(listOf(roundTripped.toEtsFile())) + val method = scene.projectClasses.flatMap { it.methods }.single { it.name == "value" } + val converted = method.cfg.stmts.filterIsInstance().single() + assertEquals("x", (converted.key as EtsStringConstant).value) + assertTrue(method.cfg.stmts.filterIsInstance().any { it.rhv is EtsPropertyRef }) + } + @Test fun `object spread preserves ordered copy operations through JSON conversion`() { val frontendDto = runFrontend( diff --git a/jacodb-ets/ts-frontend/src/dto/stmts.ts b/jacodb-ets/ts-frontend/src/dto/stmts.ts index e59fd0406..a01a92a30 100644 --- a/jacodb-ets/ts-frontend/src/dto/stmts.ts +++ b/jacodb-ets/ts-frontend/src/dto/stmts.ts @@ -32,6 +32,7 @@ import { CallExprDto, ImmediateDto, LocalDto, LValueDto, ValueDto } from "./valu export type StmtDto = | NopStmtDto | AssignStmtDto + | DefineAccessorStmtDto | CopyDataPropertiesStmtDto | DefineDataPropertyStmtDto | CallStmtDto @@ -73,6 +74,14 @@ export interface CopyDataPropertiesStmtDto { throwOnNullishSource: boolean; } +/** Define an own enumerable configurable getter descriptor, without invoking an existing setter. */ +export interface DefineAccessorStmtDto { + readonly _: "DefineAccessorStmt"; + target: LocalDto; + key: ImmediateDto; + getter: ImmediateDto; +} + export interface CallStmtDto { readonly _: "CallStmt"; expr: CallExprDto; diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index abde9cddd..48a3c3ca9 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -229,13 +229,11 @@ export function usesModuleLexicalThis(expression: ts.Expression): boolean { return visit(expression); } -/** Computed method and accessor names still lack a runtime definition. */ +/** Computed method and setter 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.isMethodDeclaration(property) - || ts.isGetAccessorDeclaration(property) - || ts.isSetAccessorDeclaration(property)) + (ts.isMethodDeclaration(property) || ts.isSetAccessorDeclaration(property)) && ts.isComputedPropertyName(property.name), )) return true; diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 5dbb88a8a..6669cfe5f 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -416,6 +416,11 @@ export class ExprLowerer { } const instance = this.lowerToLocal(node.expression); + if (this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => + ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), + )) { + return { _: "PropertyRef", instance, key: constant(fieldName, STRING_TYPE), type: fieldType }; + } return { _: "InstanceFieldRef", instance, @@ -471,19 +476,38 @@ export class ExprLowerer { } /** Lower every segment of one continuous optional chain under its preceding guards. */ - private lowerOptionalChain(node: OptionalChainSegment, chain: OptionalChain): ValueDto { + private lowerOptionalChain( + node: OptionalChainSegment, + chain: OptionalChain, + complete: (current: OptionalChainValue) => ValueDto = (current) => current.value, + ): ValueDto { const root = chain.segments[0]; const initial = ts.isCallExpression(root) ? this.lowerOptionalChainCallee(root) : { value: this.snapshotToLocal(root.expression) }; - return this.lowerOptionalChainSegments(node, chain.segments, 0, initial); + return this.lowerOptionalChainSegments(node, chain.segments, 0, initial, complete); } private lowerOptionalChainCallee(root: ts.CallExpression): OptionalChainValue { - const callee = root.expression; - if (ts.isPropertyAccessExpression(callee)) { - const staticTarget = this.classLikeSignatureOf(callee.expression); - if (staticTarget !== undefined) { + const callee = unwrapTransparentExpression(root.expression); + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + const chain = optionalChain(callee); + if (chain !== undefined) { + // A grouped chain completes before the outer call; carry its receiver through the joins. + const methodReceiver = this.materialize(constant("undefined", UNDEFINED_TYPE), this.safeTypeOf(callee.expression)); + const value = this.lowerOptionalChain(callee, chain, (current) => { + if (current.methodReceiver !== undefined) { + this.m.cfg.emit({ _: "AssignStmt", left: methodReceiver, right: current.methodReceiver }); + } + return current.value; + }); + return { value, methodReceiver }; + } + + const staticTarget = ts.isPropertyAccessExpression(callee) + ? this.classLikeSignatureOf(callee.expression) + : undefined; + if (staticTarget !== undefined && ts.isPropertyAccessExpression(callee)) { return { value: { _: "StaticFieldRef", @@ -492,7 +516,7 @@ export class ExprLowerer { staticTarget, }; } - const methodReceiver = this.snapshotToLocal(callee.expression, root.arguments); + const methodReceiver = this.snapshotToLocal(callee.expression); return { value: this.accessFromInstance(callee, methodReceiver), methodReceiver }; } return { value: this.snapshotToLocal(callee, root.arguments) }; @@ -503,8 +527,9 @@ export class ExprLowerer { segments: readonly OptionalChainSegment[], index: number, current: OptionalChainValue, + complete: (current: OptionalChainValue) => ValueDto, ): ValueDto { - if (index >= segments.length) return current.value; + if (index >= segments.length) return complete(current); const segment = segments[index]; if (ts.isCallExpression(segment)) { @@ -514,6 +539,7 @@ export class ExprLowerer { segments, index + 1, { value: this.callOptionalChainSegment(segment, callee) }, + complete, ); if (segment.questionDotToken === undefined) { return continueAfterCall(current); @@ -529,6 +555,7 @@ export class ExprLowerer { segments, index + 1, { value: this.accessFromInstance(segment, receiver), methodReceiver: receiver }, + complete, ); return segment.questionDotToken === undefined ? continueAfterAccess() @@ -536,23 +563,12 @@ export class ExprLowerer { } private callOptionalChainSegment(node: ts.CallExpression, callee: OptionalChainValue): ValueDto { - if (ts.isPropertyAccessExpression(node.expression)) { + const expression = unwrapTransparentExpression(node.expression); + if (ts.isPropertyAccessExpression(expression)) { if (callee.staticTarget !== undefined) { return { _: "StaticCallExpr", - method: this.methodSignatureForCall(node, node.expression.name.text, callee.staticTarget), - args: this.lowerCallArguments(node), - }; - } - if (callee.methodReceiver !== undefined) { - return { - _: "InstanceCallExpr", - instance: callee.methodReceiver, - method: this.methodSignatureForCall( - node, - node.expression.name.text, - this.classSignatureFromType(callee.methodReceiver.type), - ), + method: this.methodSignatureForCall(node, expression.name.text, callee.staticTarget), args: this.lowerCallArguments(node), }; } @@ -561,6 +577,7 @@ export class ExprLowerer { return { _: "PtrCallExpr", ptr, + ...(callee.methodReceiver === undefined ? {} : { receiver: callee.methodReceiver }), method: this.methodSignatureForCall(node, "%call", UNKNOWN_CLASS_SIGNATURE), args: this.lowerCallArguments(node), }; @@ -576,6 +593,11 @@ export class ExprLowerer { ): ValueDto { if (ts.isPropertyAccessExpression(node)) { const fieldType = this.safeTypeOf(node); + if (this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => + ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), + )) { + return { _: "PropertyRef", instance, key: constant(node.name.text, STRING_TYPE), type: fieldType }; + } return { _: "InstanceFieldRef", instance, @@ -621,6 +643,11 @@ export class ExprLowerer { } if (ts.isPropertyAccessExpression(node)) { const ref = this.lowerPropertyAccess(node); + if (ref._ === "PropertyRef") { + return this.mayReassign(node.expression, laterExpression) + ? { ...ref, instance: this.m.snapshotToLocal(ref.instance, ref.instance.type) } + : ref; + } if (ref._ === "InstanceFieldRef" || ref._ === "StaticFieldRef") { return ref._ === "StaticFieldRef" || !this.mayReassign(node.expression, laterExpression) ? ref @@ -926,7 +953,7 @@ export class ExprLowerer { // ------------------------------------------------------------------ lowerCall(node: ts.CallExpression): ValueDto { - const callee = node.expression; + const callee = unwrapTransparentExpression(node.expression); if (ts.isPropertyAccessExpression(callee) && this.isProjectClassProperty(callee.expression)) { this.evaluateProjectClassPropertyReceiver(callee.expression); @@ -938,6 +965,11 @@ export class ExprLowerer { return this.lowerOptionalChain(node, chain); } + if ((ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) + && optionalChain(callee) !== undefined) { + return this.callOptionalChainSegment(node, this.lowerOptionalChainCallee(node)); + } + // `super(...)` — call the superclass constructor on `this`. if (callee.kind === ts.SyntaxKind.SuperKeyword) { const thisLocal = this.m.getOrCreateLocal("this", this.m.thisType()); @@ -990,11 +1022,13 @@ export class ExprLowerer { args: this.lowerCallArguments(node), }; } - // ECMAScript evaluates the receiver before any argument. - const instance = this.snapshotToLocal(callee.expression, node.arguments); + // A property getter runs before arguments and may replace the receiver binding. + const instance = this.snapshotToLocal(callee.expression); + const ptr = this.materialize(this.accessFromInstance(callee, instance), this.safeTypeOf(callee)); return { - _: "InstanceCallExpr", - instance, + _: "PtrCallExpr", + ptr, + receiver: instance, method: this.methodSignatureForCall(node, methodName, this.classSignatureFromType(instance.type)), args: this.lowerCallArguments(node), }; @@ -1042,6 +1076,23 @@ export class ExprLowerer { }; } + if (ts.isElementAccessExpression(callee)) { + const receiver = this.snapshotToLocal(callee.expression); + const ptr = this.materialize({ + _: "PropertyRef", + instance: receiver, + key: this.lowerPropertyKey(callee.argumentExpression), + type: this.safeTypeOf(callee), + }, this.safeTypeOf(callee)); + return { + _: "PtrCallExpr", + ptr, + receiver, + method: this.methodSignatureForCall(node, "%call", UNKNOWN_CLASS_SIGNATURE), + args: this.lowerCallArguments(node), + }; + } + // Computed callee is evaluated before arguments. const ptr = this.snapshotToLocal(callee, node.arguments); return { @@ -1497,7 +1548,7 @@ export class ExprLowerer { return this.lowerClosure(node); } - private lowerClosure(node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration): LocalDto { + private lowerClosure(node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | ts.GetAccessorDeclaration): LocalDto { if (this.lowerFunctionBody === undefined) { throw new LoweringError("closure in a context without a body lowerer"); } @@ -1619,6 +1670,12 @@ export class ExprLowerer { excludedKeys: [], throwOnNullishSource: false, }); + } else if (ts.isGetAccessorDeclaration(property)) { + const key = ts.isComputedPropertyName(property.name) + ? this.lowerPropertyKey(property.name.expression) + : constant(memberName(property.name), STRING_TYPE); + const getter = this.lowerClosure(property); + this.m.cfg.emit({ _: "DefineAccessorStmt", target: temp, key, getter }); } else if (ts.isMethodDeclaration(property)) { if (ts.isComputedPropertyName(property.name)) { throw new LoweringError("computed object method name is not represented in EtsIR"); @@ -1643,7 +1700,7 @@ export class ExprLowerer { body: methodContext.build(), }); } else { - // Accessors remain separately unsupported. + // Setters remain separately unsupported. throw new LoweringError(`object literal member: ${syntaxKindName(property.kind)}`); } } @@ -1915,7 +1972,10 @@ function optionalChain(node: OptionalChainSegment): OptionalChain | undefined { if (current.questionDotToken !== undefined) { earliestOptionalIndex = reversed.length - 1; } - const parent = current.expression; + let parent: ts.Expression = current.expression; + while (ts.isNonNullExpression(parent) && ts.isNonNullChain(parent)) { + parent = parent.expression; + } if (!isOptionalChainSegment(parent)) { break; } @@ -1929,17 +1989,31 @@ function isOptionalChainSegment(node: ts.Node): node is OptionalChainSegment { return ts.isPropertyAccessChain(node) || ts.isElementAccessChain(node) || ts.isCallExpression(node); } -/** Calls and suspension can run arbitrary user code, so a later one may mutate any captured binding. */ +/** Calls, getters and implicit coercions can mutate any captured binding. */ function containsPossibleSideEffect(node: ts.Node): boolean { let found = false; const visit = (current: ts.Node): void => { - if (found || ts.isFunctionLike(current)) return; + if (found) return; + if (ts.isFunctionLike(current)) { + // Method/accessor names run at creation; their bodies run only when invoked. + found = current.name !== undefined && ts.isComputedPropertyName(current.name); + return; + } if ( ts.isCallExpression(current) || ts.isNewExpression(current) || ts.isAwaitExpression(current) || ts.isYieldExpression(current) || ts.isTaggedTemplateExpression(current) + || ts.isSpreadElement(current) + || ts.isSpreadAssignment(current) + || ts.isPropertyAccessExpression(current) + || ts.isElementAccessExpression(current) + || ts.isComputedPropertyName(current) + || ts.isBinaryExpression(current) + || ts.isPrefixUnaryExpression(current) + || ts.isPostfixUnaryExpression(current) + || ts.isTemplateExpression(current) ) { found = true; return; @@ -1998,7 +2072,7 @@ function sameBinding( * are excluded. */ function collectCapturedIdentifiers( - closure: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, + closure: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | ts.GetAccessorDeclaration, checker: ts.TypeChecker, ): ts.Identifier[] { if (closure.body === undefined) return []; diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index cbe1bf32f..770ff08f6 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -289,6 +289,8 @@ function stmtOperands(stmt: StmtDto): ValueDto[] { return [stmt.target, stmt.key, stmt.value]; case "CopyDataPropertiesStmt": return [stmt.target, stmt.source, ...stmt.excludedKeys]; + case "DefineAccessorStmt": + return [stmt.target, stmt.key, stmt.getter]; case "CallStmt": return [stmt.expr]; case "ReturnStmt": diff --git a/jacodb-ets/ts-frontend/test/evaluation-effects.spec.ts b/jacodb-ets/ts-frontend/test/evaluation-effects.spec.ts new file mode 100644 index 000000000..bef6bf07a --- /dev/null +++ b/jacodb-ets/ts-frontend/test/evaluation-effects.spec.ts @@ -0,0 +1,167 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { executeObjectIr } from "./object-runtime"; +import { lower } from "./util"; + +function expectSameResult(source: string, expected: unknown): void { + const { file, diagnostics } = lower(source); + const serialized = JSON.parse(JSON.stringify(file)); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + expect(diagnostics.messages).toEqual([]); + expect(runInNewContext(`${javascript}\nexports.value()`, { exports: {} })).toBe(expected); + expect(executeObjectIr(serialized, "value")).toBe(expected); +} + +describe("implicit effects preserve evaluated values", () => { + it("keeps the receiver selected before property-key coercion", () => { + expectSameResult(` + export function value(): number { + let object: any = { x: 3 }; + const key = { toString: function() { object = { x: 9 }; return "x"; } }; + return object[key as any]; + } + `, 3); + }); + + it("keeps an earlier call argument before a getter changes its binding", () => { + expectSameResult(` + export function value(): number { + let first = 1; + const object = { get x() { first = 9; return 2; } }; + const combine = function(a: number, b: number) { return a * 10 + b; }; + return combine(first, object.x); + } + `, 12); + }); + + it("keeps the left operand before a getter changes its binding", () => { + expectSameResult(` + export function value(): number { + let first = 1; + const object = { get x() { first = 9; return 2; } }; + return first + object.x; + } + `, 3); + }); + + it("keeps an earlier argument before computed literal key coercion", () => { + expectSameResult(` + export function value(): number { + let first = 1; + const key = { toString: function() { first = 9; return "x"; } }; + const combine = function(a: number, b: any) { return a * 10 + b.x; }; + return combine(first, { [key as any]: 2 }); + } + `, 12); + }); + + it("keeps an earlier argument before a computed getter name is coerced", () => { + expectSameResult(` + export function value(): number { + let first = 1; + const key = { toString: function() { first = 9; return "x"; } }; + const combine = function(a: number, b: any) { return a * 10 + b.x; }; + return combine(first, { get [key as any]() { return 2; } }); + } + `, 12); + }); + + it("keeps an earlier argument before spread invokes a getter", () => { + expectSameResult(` + export function value(): number { + let first = 1; + const object = { get x() { first = 9; return 2; } }; + const combine = function(a: number, b: any) { return a * 10 + b.x; }; + return combine(first, { ...object }); + } + `, 12); + }); +}); + +describe("getter-selected calls", () => { + it.each(["object.m(argument())", "object?.m(argument())"])( + "reads a getter behind a class method declaration before arguments: %s", (call) => { + expectSameResult(` + class Callable { m(value: number): number { return value; } } + export function value(): number { + let order = 0; + const object: Callable = { + get m() { order = order * 10 + 1; return function(a: number) { return a; }; } + }; + const argument = function() { order = order * 10 + 2; return 0; }; + ${call}; + return order; + } + `, 12); + }, + ); + + it.each(["object.m(argument())", "object?.m(argument())"])( + "reads a getter behind a method signature before arguments: %s", (call) => { + expectSameResult(` + interface Callable { m(value: number): number; } + export function value(): number { + let order = 0; + const object: Callable = { + get m() { order = order * 10 + 1; return function(a: number) { return a; }; } + }; + const argument = function() { order = order * 10 + 2; return 0; }; + ${call}; + return order; + } + `, 12); + }, + ); + + it.each([ + "object.m(argument())", + "object.m?.(argument())", + "object?.m(argument())", + "object?.m?.(argument())", + "object['m'](argument())", + ])("reads the callee once before arguments: %s", (call) => { + expectSameResult(` + export function value(): number { + let order = 0; + const object = { + get m() { order = order * 10 + 1; return function(a: number) { return a; }; } + }; + const argument = function() { order = order * 10 + 2; return 0; }; + ${call}; + return order; + } + `, 12); + }); + + it.each(["object.m()", "object.m?.()", "object?.m?.()", "object['m']()", "object['m']?.()", "object?.['m']?.()"])( + "retains this when the getter replaces the receiver binding: %s", (call) => { + expectSameResult(` + export function value(): number { + let object: any = { + base: 3, + get m() { object = { base: 9 }; return function() { return this.base; }; } + }; + return ${call}; + } + `, 3); + }, + ); + + it("skips arguments when the getter returns an absent optional callee", () => { + expectSameResult(` + export function value(): number { + let order = 0; + const object = { + get m(): ((a: number) => number) | undefined { order++; return undefined; } + }; + const argument = function() { order = order * 10 + 2; return 0; }; + object.m?.(argument()); + return order; + } + `, 1); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/lowering.spec.ts b/jacodb-ets/ts-frontend/test/lowering.spec.ts index 8138c44be..f8674b36f 100644 --- a/jacodb-ets/ts-frontend/test/lowering.spec.ts +++ b/jacodb-ets/ts-frontend/test/lowering.spec.ts @@ -79,13 +79,14 @@ describe("straight-line lowering", () => { }); }); - it("lowers method calls on values to InstanceCallExpr with a Local instance", () => { + it("captures callable properties before invoking them with their receiver", () => { const stmts = bodyStmts(`console.log("hello");`); const call = stmts.find((s): s is CallStmtDto => s._ === "CallStmt"); expect(call).toBeDefined(); expect(call!.expr).toMatchObject({ - _: "InstanceCallExpr", - instance: { _: "Local" }, + _: "PtrCallExpr", + ptr: { _: "Local" }, + receiver: { _: "Local" }, method: { name: "log" }, args: [{ _: "Constant", value: "hello", type: { _: "StringType" } }], }); diff --git a/jacodb-ets/ts-frontend/test/object-accessors.spec.ts b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts new file mode 100644 index 000000000..1c5841391 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-accessors.spec.ts @@ -0,0 +1,102 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { executeObjectIr } from "./object-runtime"; +import { compile, lower, methodByName } from "./util"; + +function nodeResult(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("object getter descriptors", () => { + it("preserves the getter body and invokes it on a property read", () => { + const source = `export function value(): number { return ({ get x() { return 1; } }).x; }`; + const compiled = compile(source); + expect(compiled.program.getSemanticDiagnostics(compiled.sourceFile)).toEqual([]); + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "value").body!.cfg.blocks.flatMap((block) => block.stmts); + const descriptor = stmts.find((stmt) => stmt._ === "DefineAccessorStmt"); + + expect(descriptor).toMatchObject({ + _: "DefineAccessorStmt", + key: { _: "Constant", value: "x" }, + getter: { _: "Local", type: { _: "FunctionType" } }, + }); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true); + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "value")).toBe(nodeResult(source, `exports.value()`)); + }); + + it("keeps captured bindings and calls the getter with its dynamic receiver", () => { + const source = ` + export function value(seed: number): number { + const object = { y: 2, get x() { seed++; return seed + this.y; } }; + const first = object.x; + return first * 10 + object.x; + } + `; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "value", [3])).toBe(67); + expect(nodeResult(source, `exports.value(3)`)).toBe(67); + }); + + it("evaluates a computed getter key at creation but runs its body only on Get", () => { + const source = ` + export function value(key: string): number { + let reads = 0; + const object = { get [key]() { reads++; return 4; } }; + return reads * 100 + object[key] * 10 + reads; + } + `; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "value", ["x"])).toBe(41); + expect(nodeResult(source, `exports.value("x")`)).toBe(41); + }); + + it("defines a later data property without invoking or retaining the replaced getter", () => { + const source = ` + export function value(): number { + let reads = 0; + const object = { get x() { reads++; return 4; }, ["x"]: 7 }; + return object.x * 10 + reads; + } + `; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "value")).toBe(70); + expect(nodeResult(source, `exports.value()`)).toBe(70); + }); + + it("runs a getter while copying spread and creates a data property on the target", () => { + const source = ` + export function value(): number { + let reads = 0; + const original = { get x() { reads++; return 4; } }; + const copied = { ...original }; + return copied.x * 100 + copied.x * 10 + reads; + } + `; + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "value")).toBe(441); + expect(nodeResult(source, `exports.value()`)).toBe(441); + }); + + it("keeps object setters explicitly unsupported", () => { + const { file, diagnostics } = lower(`export function value() { return { set x(value: number) {} }; }`); + const stmts = methodByName(file, "value").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(true); + expect(diagnostics.messages.some((message) => message.includes("SetAccessor"))).toBe(true); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.spec.ts b/jacodb-ets/ts-frontend/test/object-runtime.spec.ts new file mode 100644 index 000000000..bd6b444e3 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-runtime.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { EtsFileDto } from "../src/dto/model"; +import { LexicalEnvTypeDto } from "../src/dto/types"; +import { serializeEtsFile } from "../src/serialize"; +import { executeObjectIr } from "./object-runtime"; +import { lower } from "./util"; + +function lowerSerialized(source: string): EtsFileDto { + const { file, diagnostics } = lower(source); + + expect(diagnostics.messages).toEqual([]); + return JSON.parse(serializeEtsFile(file)); +} + +function rewriteCaptures(value: unknown, rewrite: (type: LexicalEnvTypeDto) => void): number { + if (value === null || typeof value !== "object") return 0; + const record = value as Record; + let changed = 0; + if (record._ === "LexicalEnvType") { + const type = value as LexicalEnvTypeDto; + changed = type.closures.length; + rewrite(type); + } + + for (const nested of Object.values(value)) changed += rewriteCaptures(nested, rewrite); + return changed; +} + +const BODY_CAPTURE = ` + function make(seed: number) { return function() { return seed; }; } + export function result() { return make(12)(); } +`; + +describe("object test interpreter closure environments", () => { + it("constructs the environment from the lifted method's declared slots", () => { + const file = lowerSerialized(BODY_CAPTURE); + const lifted = file.classes.flatMap((clazz) => clazz.methods) + .find((method) => method.signature.parameters[0]?.type._ === "LexicalEnvType")!; + const environmentType = lifted.signature.parameters[0].type; + if (environmentType._ !== "LexicalEnvType") throw new Error("expected a captured parameter"); + + expect(executeObjectIr(file, "result")).toBe(12); + environmentType.closures = []; + + expect(() => executeObjectIr(file, "result")).toThrow("undeclared test capture seed"); + }); + + it("rejects a closure reference omitted from its base's declared slots", () => { + const file = lowerSerialized(BODY_CAPTURE); + const reference = file.classes.flatMap((clazz) => clazz.methods) + .flatMap((method) => method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? []) + .flatMap((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ClosureFieldRef" ? [stmt.right] : []) + .find((value) => value.fieldName === "seed")!; + const environmentType = reference.base.type; + if (environmentType._ !== "LexicalEnvType") throw new Error("expected a captured reference"); + + expect(executeObjectIr(file, "result")).toBe(12); + environmentType.closures = []; + + expect(() => executeObjectIr(file, "result")).toThrow("undeclared test capture seed"); + }); + + it.each(["removed", "renamed"])("rejects %s getter captures in serialized IR", (mutation) => { + const file = lowerSerialized(` + export function result(seed: number) { + const object = { y: 2, get x() { seed++; return seed + this.y; } }; + const first = object.x; + return first * 10 + object.x; + } + `); + + expect(executeObjectIr(file, "result", [3])).toBe(67); + const changedSlots = rewriteCaptures(file, (type) => { + type.closures = mutation === "removed" + ? [] + : type.closures.map((slot) => ({ ...slot, name: `${slot.name}$missing` })); + }); + + expect(changedSlots).toBeGreaterThan(0); + expect(() => executeObjectIr(file, "result", [3])).toThrow("undeclared test capture seed"); + }); + + it("keeps outer writes and sibling closure writes visible through declared slots", () => { + const file = lowerSerialized(` + export function result() { + let seed = 1; + const increment = () => seed++; + const read = () => seed; + seed = 4; + return increment() * 100 + read(); + } + `); + + expect(executeObjectIr(file, "result")).toBe(405); + }); + + it("forwards a live environment through nested closures", () => { + const file = lowerSerialized(` + export function result() { + let seed = 2; + const outer = () => { + const inner = () => ++seed; + return inner; + }; + const inner = outer(); + seed = 5; + return inner() * 10 + seed; + } + `); + + expect(executeObjectIr(file, "result")).toBe(66); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts index 5d7ead032..627d997ac 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -95,7 +95,7 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] } case "PtrCallExpr": return Reflect.apply( read(value.ptr), - undefined, + value.receiver === undefined ? undefined : read(value.receiver), value.args.map(read), ); case "CastExpr": return read(value.arg); @@ -138,6 +138,19 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] 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 "DefineAccessorStmt": { + const target = read(stmt.target); + const key = read(stmt.key); + const getter = read(stmt.getter); + const old = Object.getOwnPropertyDescriptor(target, key); + Object.defineProperty(target, key, { + get: getter, + set: old?.set, + enumerable: true, + configurable: true, + }); + break; + } case "CopyDataPropertiesStmt": { const source = read(stmt.source); if (source === null || source === undefined) { @@ -154,7 +167,6 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] } break; } - case "IfStmt": next = block.successors[read(stmt.condition) ? 1 : 0]; break; case "ReturnStmt": return read(stmt.arg); case "ReturnVoidStmt": return undefined; diff --git a/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts new file mode 100644 index 000000000..5d42e086b --- /dev/null +++ b/jacodb-ets/ts-frontend/test/optional-callee-wrappers.spec.ts @@ -0,0 +1,172 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { executeObjectIr } from "./object-runtime"; +import { lower } from "./util"; + +function executions(source: string): ((...args: unknown[]) => unknown)[] { + const { file, diagnostics } = lower(source); + const serialized = JSON.parse(JSON.stringify(file)); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + expect(diagnostics.messages).toEqual([]); + const native = runInNewContext(`${javascript}\nexports.value`, { exports: {} }); + return [native, (...args) => executeObjectIr(serialized, "value", args)]; +} + +describe("transparent optional callees", () => { + it.each([ + "(object.m)?.()", + "(object.m)()", + "(object['m'])?.()", + "(object['m'] as any)?.()", + "(object['m'] as any)()", + "(object['m'])?.()", + "object['m']!?.()", + "(object['m'] satisfies any)?.()", + "(object?.['m'])?.()", + "(object?.['m'])()", + "(object?.['m'] as any)?.()", + "object?.['m']!()", + "(object?.['m'])!()", + "((object?.['m'] as any)!)?.()", + ])("keeps the getter's original receiver and reads it once: %s", (call) => { + const source = ` + export function value(): number { + let reads = 0; + let object: any = { + base: 3, + get m() { + reads++; + object = { base: 9 }; + return function() { return this.base * 10 + reads; }; + } + }; + return ${call}; + } + `; + + for (const execute of executions(source)) { + expect(execute()).toBe(31); + } + }); + + it.each([ + "(object?.[key()])?.(argument())", + "(object?.[key()] as any)?.(argument())", + "(object?.[key()] satisfies any)?.(argument())", + "object?.[key()]!?.(argument())", + "object?.[key()]!(argument())", + "object?.[key()]!.n?.(argument())", + ])("keeps inner guards and skips keys and arguments on null: %s", (call) => { + const source = ` + export function value(): number { + let order = 0; + const object: any = null; + const key = function() { order = order * 10 + 1; return "m"; }; + const argument = function() { order = order * 10 + 2; return 0; }; + ${call}; + return order; + } + `; + + for (const execute of executions(source)) { + expect(execute()).toBe(0); + } + }); + + it("skips arguments after a grouped getter returns an absent callee", () => { + const source = ` + export function value(): number { + let order = 0; + const object: any = { get m() { order = order * 10 + 2; return undefined; } }; + const key = function() { order = order * 10 + 1; return "m"; }; + const argument = function() { order = order * 10 + 3; return 0; }; + (object?.[key()] as any)?.(argument()); + return order; + } + `; + + for (const execute of executions(source)) { + expect(execute()).toBe(12); + } + }); + + it("retains the nested receiver before key coercion, getter and argument effects", () => { + const source = ` + export function value(): number { + let order = 0; + let object: any = { + child: { + base: 3, + get m() { + order = order * 10 + 2; + object = null; + return function(a: number) { return this.base * 100 + a; }; + } + } + }; + const key = { toString: function() { + order = order * 10 + 1; + object = { child: { base: 9 } }; + return "m"; + } }; + const argument = function() { order = order * 10 + 3; return order; }; + return ((object?.child?.[key as any]) as any)?.(argument()); + } + `; + + for (const execute of executions(source)) { + expect(execute()).toBe(423); + } + }); + + it.each(["null", "{ child: null }"])("skips nested keys and arguments for %s", (initial) => { + const source = ` + export function value(): number { + let order = 0; + const object: any = ${initial}; + const key = function() { order = order * 10 + 1; return "m"; }; + const argument = function() { order = order * 10 + 2; return 0; }; + (object?.child?.[key()] as any)?.(argument()); + return order; + } + `; + + for (const execute of executions(source)) { + expect(execute()).toBe(0); + } + }); + + it.each([ + ["(object?.[key()])(argument())", 2], + ["(object?.[key()])!(argument())", 2], + ["(object?.[key()] as any)(argument())", 2], + ["(object?.[key()]).n?.(argument())", 0], + ["(object?.[key()] as any).n?.(argument())", 0], + ])("retains the chain boundary and throwing order: %s", (call, expectedOrder) => { + const source = ` + export function value(object: any, state: { order: number }): unknown { + const key = function() { state.order = state.order * 10 + 1; return "m"; }; + const argument = function() { state.order = state.order * 10 + 2; return 0; }; + return ${call}; + } + `; + + for (const execute of executions(source)) { + const state = { order: 0 }; + let thrown: unknown; + + try { + execute(null, state); + } catch (error) { + thrown = error; + } + + expect(thrown).toHaveProperty("name", "TypeError"); + expect(state.order).toBe(expectedOrder); + } + }); +}); diff --git a/jacodb-ets/ts-frontend/test/state-and-order.spec.ts b/jacodb-ets/ts-frontend/test/state-and-order.spec.ts index 8ca6d196a..0c82fb2bd 100644 --- a/jacodb-ets/ts-frontend/test/state-and-order.spec.ts +++ b/jacodb-ets/ts-frontend/test/state-and-order.spec.ts @@ -268,8 +268,12 @@ describe("call evaluation order", () => { expect(snapshot).toBeDefined(); expect(stmts.indexOf(snapshot!)).toBeLessThan(stmts.indexOf(mutation!)); expect(call).toMatchObject({ - expr: { instance: (snapshot as Extract).left }, + expr: { _: "PtrCallExpr", receiver: (snapshot as Extract).left }, }); + const selectedMethod = stmts.find((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "InstanceFieldRef" && stmt.right.field.name === "run")!; + expect(stmts.indexOf(selectedMethod)).toBeLessThan(stmts.indexOf(mutation!)); + expect(call).toMatchObject({ expr: { ptr: (selectedMethod as Extract).left } }); }); it("keeps the receiver for optional method calls", () => { @@ -283,13 +287,14 @@ describe("call evaluation order", () => { (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceFieldRef" && stmt.right.field.name === "run", ); const call = stmts.find( - (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceCallExpr" && stmt.right.method.name === "run", + (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr", ); expect(methodRead).toBeDefined(); expect(call).toMatchObject({ right: { - _: "InstanceCallExpr", - instance: (methodRead as Extract).right._ === "InstanceFieldRef" + _: "PtrCallExpr", + ptr: (methodRead as Extract).left, + receiver: (methodRead as Extract).right._ === "InstanceFieldRef" ? (methodRead as Extract).right.instance : undefined, }, @@ -536,13 +541,14 @@ describe("expression evaluation snapshots", () => { `); const receiverStmts = flattened(methodByName(file, "receiver")); const receiverCall = receiverStmts.find( - (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "InstanceCallExpr" && stmt.expr.method.name === "run", + (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "PtrCallExpr" && stmt.expr.method.name === "run", ) as Extract; - expect(receiverCall.expr.instance.name).toMatch(/^%/); + const receiver = (receiverCall.expr as { receiver: { name: string } }).receiver; + expect(receiver.name).toMatch(/^%/); expect(receiverStmts.some( (stmt) => stmt._ === "AssignStmt" && stmt.left._ === "Local" - && stmt.left.name === receiverCall.expr.instance.name + && stmt.left.name === receiver.name && stmt.right._ === "Local" && stmt.right.name === "service", )).toBe(true); @@ -672,8 +678,8 @@ describe("expression evaluation snapshots", () => { const blocks = methodByName(file, "f").body!.cfg.blocks; const optionalCallBlock = blocks.find((block) => block.stmts.some( (stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "InstanceCallExpr" - && stmt.right.method.name === "b", + && stmt.right._ === "PtrCallExpr" + && stmt.right.method.name === "%call", )); const cAccessBlock = blocks.find((block) => block.stmts.some( (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceFieldRef" && stmt.right.field.name === "c", @@ -701,8 +707,7 @@ describe("expression evaluation snapshots", () => { ) as Extract; const methodCall = stmts.find( (stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "InstanceCallExpr" - && stmt.right.method.name === "b", + && stmt.right._ === "PtrCallExpr", ) as Extract; const callBlock = blocks.find((block) => block.stmts.includes(methodCall)); const cAccessBlock = blocks.find((block) => block.stmts.some( @@ -714,7 +719,8 @@ describe("expression evaluation snapshots", () => { ); expect(methodRead).toBeDefined(); expect(methodCall).toBeDefined(); - expect((methodCall.right as { instance: unknown }).instance) + expect((methodCall.right as { ptr: unknown }).ptr).toEqual(methodRead.left); + expect((methodCall.right as { receiver: unknown }).receiver) .toEqual((methodRead.right as { instance: unknown }).instance); expect(cAccessBlock).toBe(callBlock); expect(guards).toHaveLength(1); @@ -731,10 +737,9 @@ describe("expression evaluation snapshots", () => { const stmts = blocks.flatMap((block) => block.stmts); const call = stmts.find( (stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "InstanceCallExpr" - && stmt.right.method.name === "b", + && stmt.right._ === "PtrCallExpr", ) as Extract; - const receiver = (call.right as { instance: { name: string } }).instance; + const receiver = (call.right as { receiver: { name: string } }).receiver; const receiverSnapshot = stmts.find( (stmt) => stmt._ === "AssignStmt" && stmt.left._ === "Local" @@ -772,8 +777,7 @@ describe("expression evaluation snapshots", () => { ) as Extract; const methodCall = stmts.find( (stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "InstanceCallExpr" - && stmt.right.method.name === "c", + && stmt.right._ === "PtrCallExpr", ) as Extract; const bAccessBlock = blocks.find((block) => block.stmts.includes(bAccess)); const methodReadBlock = blocks.find((block) => block.stmts.includes(methodRead)); @@ -793,7 +797,8 @@ describe("expression evaluation snapshots", () => { expect(methodRead).toBeDefined(); expect(methodCall).toBeDefined(); expect(methodReadBlock).toBe(bAccessBlock); - expect((methodCall.right as { instance: unknown }).instance) + expect((methodCall.right as { ptr: unknown }).ptr).toEqual(methodRead.left); + expect((methodCall.right as { receiver: unknown }).receiver) .toEqual((methodRead.right as { instance: unknown }).instance); expect(dAccessBlock).toBe(methodCallBlock); expect(guards).toHaveLength(2); diff --git a/jacodb-ets/ts-frontend/test/try.spec.ts b/jacodb-ets/ts-frontend/test/try.spec.ts index 6b7b8c2e5..b17c440c7 100644 --- a/jacodb-ets/ts-frontend/test/try.spec.ts +++ b/jacodb-ets/ts-frontend/test/try.spec.ts @@ -182,7 +182,7 @@ describe("try/catch/finally lowering", () => { b.stmts.some( (s) => s._ === "CallStmt" && - s.expr._ === "InstanceCallExpr" && + s.expr._ === "PtrCallExpr" && (s.expr.args[0] as { value?: string })?.value === "finally", ), ); @@ -205,7 +205,17 @@ describe("try/catch/finally lowering", () => { .filter((s) => s._ === "CallStmt") .map((s) => ((s as { expr: { args: { value?: string }[] } }).expr.args[0] ?? {}).value); expect(logged).toEqual(["try", "finally", "finally"]); - expect(blocks.flatMap((block) => block.exceptionalSuccessors ?? [])).toHaveLength(1); + const protectedBlock = blocks.find((block) => (block.exceptionalSuccessors?.length ?? 0) > 0)!; + const exceptional = protectedBlock.exceptionalSuccessors!; + + expect(exceptional).toHaveLength(2); + expect(new Set(exceptional.map((edge) => edge.target)).size).toBe(1); + expect(protectedBlock.stmts[exceptional[0].stmtIndex]).toMatchObject({ + _: "AssignStmt", right: { _: "InstanceFieldRef", field: { name: "log" } }, + }); + expect(protectedBlock.stmts[exceptional[1].stmtIndex]).toMatchObject({ + _: "CallStmt", expr: { _: "PtrCallExpr", method: { name: "log" } }, + }); }); it("runs finally and rethrows when a call fails", () => {