From 62ece1c77de37413d63565ea2527514f76c84add Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 23:41:44 +0300 Subject: [PATCH 1/2] [TS] Restore combined frontend regression coverage --- .../jacodb/ets/test/EtsTsCallLoweringTest.kt | 191 +++++++ .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 465 ++++++++++++++---- .../ts-frontend/src/lowering/exprLowering.ts | 2 +- .../test/array-and-params-semantics.spec.ts | 175 +++++++ jacodb-ets/ts-frontend/test/cfg.spec.ts | 2 +- jacodb-ets/ts-frontend/test/closures.spec.ts | 2 +- .../ts-frontend/test/lexical-this.spec.ts | 43 +- jacodb-ets/ts-frontend/test/lowering.spec.ts | 38 +- .../ts-frontend/test/object-computed.spec.ts | 72 ++- .../ts-frontend/test/object-copy.spec.ts | 88 +++- .../ts-frontend/test/object-runtime.spec.ts | 8 +- jacodb-ets/ts-frontend/test/object-runtime.ts | 1 - .../ts-frontend/test/regressions.spec.ts | 203 +++++++- .../ts-frontend/test/spread-calls.spec.ts | 216 ++++++++ jacodb-ets/ts-frontend/test/types.spec.ts | 2 +- 15 files changed, 1370 insertions(+), 138 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt create mode 100644 jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts create mode 100644 jacodb-ets/ts-frontend/test/spread-calls.spec.ts diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt new file mode 100644 index 000000000..b53f43daa --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

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

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

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jacodb.ets.dto.ArrayRefDto +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.CallExprDto +import org.jacodb.ets.dto.ClassValueRefDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.LocalDto +import org.jacodb.ets.dto.NewClassExprDto +import org.jacodb.ets.dto.NewExprDto +import org.jacodb.ets.dto.PtrCallExprDto +import org.jacodb.ets.dto.RawValueDto +import org.jacodb.ets.dto.SpreadExpansionExprDto +import org.jacodb.ets.dto.TemplateObjectExprDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsNewClassExpr +import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsPtrCallExpr +import org.jacodb.ets.model.EtsSpreadExpansionExpr +import org.jacodb.ets.model.EtsTemplateObjectExpr +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.getOperands +import org.junit.jupiter.api.Test +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EtsTsCallLoweringTest { + private fun frontendRoundTrip(source: String): EtsFileDto { + val input = createTempDirectory("ts-calls-test").resolve("test.ts") + input.writeText(source) + + val output = generateEtsIR( + input, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + val dto = EtsFileDto.loadFromJson(output.readText()) + return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto)) + } + + private fun assignments(dto: EtsFileDto, method: String): List = + dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == method }.body!!.cfg.blocks + .flatMap { it.stmts }.filterIsInstance() + + @Test + fun `comma constructor values decode as locals after computed access`() { + val dto = frontendRoundTrip( + """ + class A { x = 1; } + export function computed(holder: any): A { return new (holder['sideEffect'], A)(); } + export function direct(): A { return new (0, A)(); } + """.trimIndent(), + ) + + for (name in listOf("computed", "direct")) { + val stmts = assignments(dto, name) + val allocation = stmts.single { it.right is NewExprDto } + val constructor = (allocation.right as NewExprDto).constructorValue as LocalDto + val capture = stmts.single { it.left == constructor && it.right is ClassValueRefDto } + + assertEquals("A", (capture.right as ClassValueRefDto).signature.name) + assertTrue(stmts.indexOf(capture) < stmts.indexOf(allocation)) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == name }.cfg.stmts.filterIsInstance() + val newExpr = model.single { it.rhv is EtsNewExpr }.rhv as EtsNewExpr + assertTrue(newExpr.constructorValue != null) + } + } + + @Test + fun `call and constructor spreads preserve iterator expansion and ordered arguments`() { + val dto = frontendRoundTrip( + """ + function add(a: number, b: number): number { return a + b; } + class Point { constructor(public x: number, public y: number) {} } + export function sum(pair: [number, number]): number { return add(...pair); } + export function make(pair: [number, number]): number { return new Point(...pair).y; } + """.trimIndent(), + ) + + for ((name, target) in listOf("sum" to "add", "make" to "constructor")) { + val stmts = assignments(dto, name) + val expansion = stmts.single { it.right is SpreadExpansionExprDto } + val reads = stmts.filter { it.right is ArrayRefDto } + val call = stmts.single { (it.right as? CallExprDto)?.method?.name == target }.right as CallExprDto + + assertEquals(2, (expansion.right as SpreadExpansionExprDto).expectedCount) + assertEquals(2, reads.size) + assertEquals(reads.map { it.left }, call.args) + assertTrue(reads.all { (it.right as ArrayRefDto).array == expansion.left }) + assertTrue(stmts.indexOf(expansion) < stmts.indexOf(reads.first())) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == name }.cfg.stmts.filterIsInstance() + val expanded = model.single { it.rhv is EtsSpreadExpansionExpr }.rhv as EtsSpreadExpansionExpr + assertEquals(2, expanded.expectedCount) + assertEquals(listOf(expanded.iterable), expanded.getOperands().toList()) + } + } + + @Test + fun `class expressions retain constructor identity field initialization and visitor support`() { + val dto = frontendRoundTrip( + """ + export function value(): number { + const Local = class { x = 1; }; + return new Local().x; + } + """.trimIndent(), + ) + val stmts = assignments(dto, "value") + val creation = stmts.single { it.right is NewClassExprDto }.right as NewClassExprDto + val clazz = dto.classes.single { it.signature == creation.signature } + val allocation = stmts.single { it.right is NewExprDto }.right as NewExprDto + + assertEquals(creation.signature, (allocation.classType as org.jacodb.ets.dto.ClassTypeDto).signature) + assertTrue(clazz.fields.any { it.signature.name == "x" }) + assertTrue(clazz.methods.any { it.signature.name == "%instInit" && it.body != null }) + assertTrue(clazz.methods.any { it.signature.name == "constructor" && it.body != null }) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "value" }.cfg.stmts.filterIsInstance() + val newClass = model.single { it.rhv is EtsNewClassExpr }.rhv as EtsNewClassExpr + assertTrue(newClass.getOperands().none()) + assertEquals(creation.signature.name, newClass.signature.name) + } + + @Test + fun `tagged template objects preserve raw cooked undefined sites and method receivers`() { + val dto = frontendRoundTrip( + """ + class Tagger { + tag(parts: TemplateStringsArray, value: number): string { return parts.raw[0] + value; } + } + export function value(tagger: Tagger): string { return tagger.tag`\unicode${'$'}{1}`; } + """.trimIndent(), + ) + val stmts = assignments(dto, "value") + val creation = stmts.single { it.right is TemplateObjectExprDto } + val template = creation.right as TemplateObjectExprDto + val call = stmts.single { it.right is PtrCallExprDto }.right as PtrCallExprDto + + assertEquals(listOf(null, ""), template.cooked) + assertEquals(listOf("\\unicode", ""), template.raw) + assertTrue(template.siteId.isNotEmpty()) + assertEquals(creation.left, call.args.first()) + assertTrue(call.receiver != null) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "value" }.cfg.stmts.filterIsInstance() + val modelTemplate = model.single { it.rhv is EtsTemplateObjectExpr }.rhv as EtsTemplateObjectExpr + val modelCall = model.single { it.rhv is EtsPtrCallExpr }.rhv as EtsPtrCallExpr + assertEquals(template.siteId, modelTemplate.siteId) + assertEquals(template.cooked, modelTemplate.cooked) + assertEquals(template.raw, modelTemplate.raw) + assertTrue(modelTemplate.getOperands().none()) + assertTrue(modelCall.getOperands().any { it == modelCall.receiver }) + } +} 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 1c7655731..bef752106 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 @@ -16,71 +16,93 @@ package org.jacodb.ets.test +import java.math.BigInteger +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +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 import kotlinx.serialization.encodeToString 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.AwaitExprDto +import org.jacodb.ets.dto.BigIntTypeDto import org.jacodb.ets.dto.BooleanTypeDto import org.jacodb.ets.dto.CaughtExceptionRefDto 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.CopyDataPropertiesStmtDto +import org.jacodb.ets.dto.DefineAccessorStmtDto import org.jacodb.ets.dto.DefineDataPropertyStmtDto import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.FunctionTypeDto import org.jacodb.ets.dto.IfStmtDto +import org.jacodb.ets.dto.InstanceCallExprDto import org.jacodb.ets.dto.InstanceOfExprDto +import org.jacodb.ets.dto.LexicalEnvTypeDto import org.jacodb.ets.dto.LocalDto 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.PtrCallExprDto import org.jacodb.ets.dto.RawStmtDto import org.jacodb.ets.dto.RawValueDto import org.jacodb.ets.dto.RelationOperationDto -import org.jacodb.ets.dto.ReturnStmtDto import org.jacodb.ets.dto.RequireObjectCoercibleExprDto -import org.jacodb.ets.dto.StringTypeDto +import org.jacodb.ets.dto.ReturnStmtDto import org.jacodb.ets.dto.StaticCallExprDto import org.jacodb.ets.dto.StaticFieldRefDto +import org.jacodb.ets.dto.StringTypeDto +import org.jacodb.ets.dto.SymbolTypeDto 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 +import org.jacodb.ets.dto.YieldExprDto 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.EtsAwaitExpr +import org.jacodb.ets.model.EtsBigIntConstant +import org.jacodb.ets.model.EtsBigIntType import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsCaughtExceptionRef import org.jacodb.ets.model.EtsClassValueRef import org.jacodb.ets.model.EtsClassValueType import org.jacodb.ets.model.EtsClosureFieldRef +import org.jacodb.ets.model.EtsCopyDataPropertiesStmt +import org.jacodb.ets.model.EtsDefineAccessorStmt import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsEqExpr +import org.jacodb.ets.model.EtsFunctionType 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.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsNumberConstant import org.jacodb.ets.model.EtsPropertyRef +import org.jacodb.ets.model.EtsPtrCallExpr 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.EtsStringConstant +import org.jacodb.ets.model.EtsSymbolType import org.jacodb.ets.model.EtsThrowStmt import org.jacodb.ets.model.EtsToPropertyKeyExpr +import org.jacodb.ets.model.EtsYieldExpr import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME import org.jacodb.ets.utils.EtsIrProvider @@ -88,14 +110,6 @@ import org.jacodb.ets.utils.defaultProviderFor import org.jacodb.ets.utils.generateEtsIR import org.jacodb.ets.utils.getOperands import org.junit.jupiter.api.Test -import kotlin.io.path.createDirectories -import kotlin.io.path.createTempDirectory -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 /** * Tests for the native TypeScript frontend (`jacodb-ets/ts-frontend`). @@ -126,43 +140,175 @@ class EtsTsFrontendTest { } @Test - fun `computed object binding reuses its normalized key in rest through JSON conversion`() { + fun `symbol parameter and return types survive frontend JSON and model conversion`() { + val frontendDto = runFrontend("export function same(value: symbol): symbol { return value; }") + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val methodDto = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "same" } + + assertEquals(SymbolTypeDto, methodDto.signature.parameters.single().type) + assertEquals(SymbolTypeDto, methodDto.signature.returnType) + + val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "same" } + + assertEquals(EtsSymbolType, modelMethod.signature.parameters.single().type) + assertEquals(EtsSymbolType, modelMethod.signature.returnType) + } + + @Test + fun `stored arrow lexical receiver survives JSON and model conversion`() { val frontendDto = runFrontend( """ - export function pick(input: any, key: any): number { - const { [key]: value, ...rest } = input; - return value + rest.y; + class Box { + offset = 11; + installOn(target: Box) { + target.callback = (value: number) => this.offset + value; + } + installOrdinaryOn(target: Box) { + target.callback = function(value: number) { return this.offset + value; }; + } + } + """.trimIndent(), + ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val box = roundTripped.toEtsFile().classes.single { it.name == "Box" } + val install = box.methods.single { it.name == "installOn" } + val arrowValue = install.cfg.stmts.filterIsInstance() + .map { it.rhv } + .filterIsInstance() + .single { it.name.startsWith("%AM") } + val arrow = box.methods.single { it.name == arrowValue.name } + val ordinary = box.methods.single { it.name.endsWith("\$installOrdinaryOn") } + + assertTrue((arrowValue.type as EtsFunctionType).isArrow) + val receiver = arrow.cfg.stmts.filterIsInstance() + .single { (it.lhv as? EtsLocal)?.name == "this" }.rhv + assertTrue(receiver is EtsClosureFieldRef) + assertEquals("this", receiver.fieldName) + assertTrue(ordinary.cfg.stmts.filterIsInstance() + .single { (it.lhv as? EtsLocal)?.name == "this" }.rhv is org.jacodb.ets.model.EtsThis) + } + + @Test + fun `bigint types and exact constants survive frontend JSON and model conversion`() { + val frontendDto = runFrontend( + """ + export function increment(x: bigint): bigint { return x + 1n; } + export const large = 9007199254740993n; + export const negative = -0x20n; + """.trimIndent(), + ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val defaultClass = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + val incrementDto = defaultClass.methods.single { it.signature.name == "increment" } + val constantsDto = defaultClass.methods.flatMap { it.body?.cfg?.blocks.orEmpty() } + .flatMap { it.stmts } + .filterIsInstance() + .mapNotNull { it.right as? ConstantDto } + .filter { it.type == BigIntTypeDto } + + assertEquals(BigIntTypeDto, incrementDto.signature.parameters.single().type) + assertEquals(BigIntTypeDto, incrementDto.signature.returnType) + assertTrue(constantsDto.any { it.value == "9007199254740993" }) + assertTrue(constantsDto.any { it.value == "-32" }) + + val modelClass = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + val incrementModel = modelClass.methods.single { it.name == "increment" } + val bigInts = modelClass.methods.flatMap { it.cfg.stmts } + .filterIsInstance() + .mapNotNull { it.rhv as? EtsBigIntConstant } + + assertEquals(EtsBigIntType, incrementModel.signature.parameters.single().type) + assertEquals(EtsBigIntType, incrementModel.signature.returnType) + assertTrue(bigInts.any { it.value == BigInteger("9007199254740993") }) + assertTrue(bigInts.any { it.value == BigInteger("-32") }) + } + + @Test + fun `for await preserves async acquisition and sync value unwrapping`() { + val frontendDto = runFrontend( + """ + export async function first(input: AsyncIterable): Promise { + for await (const value of input) return value; + return -1; + } + export async function fromArray(input: number[]): Promise { + for await (const value of input) return value; + return -1; } """.trimIndent(), ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + for (name in listOf("first", "fromArray")) { + val methodDto = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == name } + val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() + val calls = assignments.mapNotNull { it.right as? PtrCallExprDto } + val awaits = assignments.mapNotNull { it.right as? AwaitExprDto } + val next = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "next" } + + assertTrue(calls.any { it.method.name == "Symbol.asyncIterator" && it.receiver != null }) + assertTrue(calls.any { it.method.name == "Symbol.iterator" && it.receiver != null }) + assertEquals(2, awaits.size) + assertTrue(awaits.any { it.arg == next.left }) + assertTrue(assignments.none { it.right is RawValueDto }) + + val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == name } + assertEquals(2, modelMethod.cfg.stmts.filterIsInstance() + .count { it.rhv is EtsAwaitExpr }) + } + } + + @Test + fun `computed object keys survive the frontend JSON round trip`() { + val frontendDto = runFrontend( + """ + export function read(key: string): number { + const object = { [key]: 1 }; + const { [key]: value } = object; + return value; + } + """.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 == "pick" }.body!!.cfg.blocks.flatMap { it.stmts } + .methods.single { it.signature.name == "read" } + .body!!.cfg.blocks.flatMap { it.stmts } val assignments = dtoStmts.filterIsInstance() - val read = assignments.mapNotNull { it.right as? PropertyRefDto }.single() - val copy = dtoStmts.filterIsInstance().single() - assertEquals(expected = 1, actual = assignments.count { it.right is ToPropertyKeyExprDto }) - assertEquals(expected = read.key, actual = copy.excludedKeys.single()) + 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 method = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } - .methods.single { it.name == "pick" } + 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() - val modelRead = converted.mapNotNull { it.rhv as? EtsPropertyRef }.single() - val modelCopy = method.cfg.stmts.filterIsInstance().single() - - assertEquals(expected = 1, actual = converted.count { it.rhv is EtsToPropertyKeyExpr }) - assertEquals(expected = modelRead.key, actual = modelCopy.excludedKeys.single()) + assertEquals(1, method.cfg.stmts.filterIsInstance().size) + assertTrue(converted.any { it.rhv is EtsPropertyRef }) + assertEquals(2, converted.count { it.rhv is EtsToPropertyKeyExpr }) } @Test - fun `object rest preserves excluded keys through JSON conversion`() { + fun `object spread and rest preserve copy operations through JSON conversion`() { val frontendDto = runFrontend( """ export function copy(input: { x: number; y: number }): number { - const { x, ...rest } = input; + const object = { ...input, y: 7 }; + const { x, ...rest } = object; return rest.y + x; } """.trimIndent(), @@ -175,17 +321,17 @@ class EtsTsFrontendTest { .body!!.cfg.blocks.flatMap { it.stmts } val copies = dtoStmts.filterIsInstance() - assertTrue(dtoStmts.filterIsInstance().any { it.right is RequireObjectCoercibleExprDto }) - assertEquals(1, copies.size) - assertTrue(copies[0].throwOnNullishSource) - assertEquals("x", (copies[0].excludedKeys.single() as ConstantDto).value) + assertEquals(2, copies.size) + assertFalse(copies[0].throwOnNullishSource) + assertTrue(copies[1].throwOnNullishSource) + assertEquals("x", (copies[1].excludedKeys.single() as ConstantDto).value) 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) - assertEquals("x", (converted[0].excludedKeys.single() as EtsStringConstant).value) + assertEquals(2, converted.size) + assertEquals("x", (converted[1].excludedKeys.single() as EtsStringConstant).value) } @Test @@ -219,65 +365,59 @@ class EtsTsFrontendTest { } @Test - fun `object spread preserves ordered copy operations through JSON conversion`() { - val frontendDto = runFrontend( + fun `array expansion and parameter defaults survive frontend JSON conversion`() { + val dto = runFrontend( """ - export function copy(input: { x: number; y: number }): number { - const object = { ...input, y: 7 }; - return object.y + object.x; - } + export function defaults(x = 3): number { return x; } + export function spread(input: number[]): number[] { return [0, ...input]; } + export function tail(input: number[]): number[] { const [first, ...rest] = input; return rest; } + export function count(...values: number[]): number { return values.length; } """.trimIndent(), ) - - val serialized = Json { serializersModule = dtoModule }.encodeToString(frontendDto) + val serialized = Json { serializersModule = dtoModule }.encodeToString(dto) 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 defaultClass = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + val modelClass = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } - 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()) + assertTrue(defaultClass.methods.single { it.signature.name == "count" }.signature.parameters.single().isRest) + assertTrue(modelClass.methods.single { it.name == "defaults" }.cfg.stmts.any { it is EtsIfStmt }) + assertTrue(modelClass.methods.single { it.name == "spread" }.cfg.stmts.any { + it is EtsAssignStmt && it.rhv is EtsNewArrayExpr + }) + assertTrue(modelClass.methods.single { it.name == "tail" }.cfg.stmts.any { + it is EtsAssignStmt && it.rhv is EtsNewArrayExpr + }) + val spreadCalls = defaultClass.methods.single { it.signature.name == "spread" }.body!!.cfg.blocks + .flatMap { it.stmts }.filterIsInstance().mapNotNull { it.right as? PtrCallExprDto } + val modelCalls = modelClass.methods.single { it.name == "spread" }.cfg.stmts + .filterIsInstance().mapNotNull { it.rhv as? EtsPtrCallExpr } + + assertEquals(1, spreadCalls.size) + assertEquals(1, modelCalls.size) + assertTrue(spreadCalls.single().receiver is LocalDto) + assertEquals((spreadCalls.single().receiver as LocalDto).name, modelCalls.single().receiver!!.name) + assertTrue(modelCalls.single().getOperands().any { it == modelCalls.single().receiver }) } @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) + fun `regular expression literal preserves pattern and flags through JSON conversion`() { + val dto = runFrontend("export function containsX(value: string): boolean { return /x+/gi.test(value); }") + val serialized = Json { serializersModule = dtoModule }.encodeToString(dto) 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 }) + val method = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "containsX" } + val assignments = method.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() + val constructor = assignments.mapNotNull { it.right as? InstanceCallExprDto } + .single { it.method.name == "constructor" } + val testCall = assignments.mapNotNull { it.right as? PtrCallExprDto } + .single { it.method.name == "test" } + + assertEquals(listOf("x+", "gi"), constructor.args.map { (it as ConstantDto).value }) + assertTrue(testCall.receiver is LocalDto) + assertTrue(roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "containsX" }.cfg.stmts.any { + it is EtsAssignStmt && it.rhv is EtsNewExpr + }) } @Test @@ -503,7 +643,7 @@ class EtsTsFrontendTest { } @Test - fun `computed constructor access stays unsupported through JSON and model conversion`() { + fun `computed constructor access keeps property semantics through JSON and model conversion`() { val frontendDto = runFrontend( """ class A {} @@ -516,15 +656,18 @@ class EtsTsFrontendTest { .methods.single { it.signature.name == "make" } val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() - assertTrue(assignments.any { (it.right as? RawValueDto)?.kind == "UnsupportedValue" }) - assertTrue(assignments.none { it.right is ArrayRefDto || it.right is NewExprDto }) + val property = assignments.single { it.right is org.jacodb.ets.dto.PropertyRefDto } + val allocation = assignments.single { it.right is NewExprDto }.right as NewExprDto + assertEquals(property.left as LocalDto, allocation.constructorValue as LocalDto) + assertTrue(assignments.none { it.right is ArrayRefDto || it.right is RawValueDto }) val modelMethod = frontendDto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } .methods.single { it.name == "make" } val modelAssignments = modelMethod.cfg.stmts.filterIsInstance() - assertTrue(modelAssignments.any { it.rhv is EtsRawEntity }) - assertTrue(modelAssignments.none { it.rhv is EtsNewExpr }) + assertTrue(modelAssignments.any { it.rhv is org.jacodb.ets.model.EtsPropertyRef }) + assertTrue(modelAssignments.any { it.rhv is EtsNewExpr }) + assertTrue(modelAssignments.none { it.rhv is EtsRawEntity }) } @Test @@ -1093,6 +1236,37 @@ class EtsTsFrontendTest { assertTrue(method.cfg.throwers(catcher).containsAll(listOf(comparison, fieldStore))) } + @Test + fun `yield delegation survives frontend JSON and model conversion`() { + val frontendDto = runFrontend( + """ + export function* values() { + yield* [1, 2]; + yield [3]; + } + """.trimIndent(), + ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val methodDto = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "values" } + val yieldsDto = methodDto.body!!.cfg.blocks.flatMap { it.stmts } + .filterIsInstance() + .mapNotNull { it.right as? YieldExprDto } + + assertEquals(listOf(true, false), yieldsDto.map { it.isDelegating }) + + val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "values" } + val modelYields = modelMethod.cfg.stmts.filterIsInstance() + .mapNotNull { it.rhv as? EtsYieldExpr } + + assertEquals(listOf(true, false), modelYields.map { it.isDelegating }) + assertTrue(modelYields[0].toString().startsWith("yield* ")) + assertTrue(modelYields[1].toString().startsWith("yield ")) + } + @Test fun `closures, destructuring and object literals convert to the model`() { val etsFileDto = runFrontend( @@ -1174,4 +1348,97 @@ class EtsTsFrontendTest { val method = clazz.methods.single { it.name == DEFAULT_ARK_METHOD_NAME } assertTrue(method.cfg.stmts.isNotEmpty(), "default method must have a non-empty body") } + + @Test + fun `computed object binding reuses its normalized key in rest through JSON conversion`() { + val frontendDto = runFrontend( + """ + export function pick(input: any, key: any): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + """.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 == "pick" }.body!!.cfg.blocks.flatMap { it.stmts } + val assignments = dtoStmts.filterIsInstance() + val read = assignments.mapNotNull { it.right as? PropertyRefDto }.single() + val copy = dtoStmts.filterIsInstance().single() + + assertEquals(expected = 1, actual = assignments.count { it.right is ToPropertyKeyExprDto }) + assertEquals(expected = read.key, actual = copy.excludedKeys.single()) + assertTrue(assignments.none { it.right is RawValueDto }) + + val method = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "pick" } + val converted = method.cfg.stmts.filterIsInstance() + val modelRead = converted.mapNotNull { it.rhv as? EtsPropertyRef }.single() + val modelCopy = method.cfg.stmts.filterIsInstance().single() + + assertEquals(expected = 1, actual = converted.count { it.rhv is EtsToPropertyKeyExpr }) + assertEquals(expected = modelRead.key, actual = modelCopy.excludedKeys.single()) + } + + @Test + fun `object rest preserves excluded keys through JSON conversion`() { + val frontendDto = runFrontend( + """ + export function copy(input: { x: number; y: number }): number { + const { x, ...rest } = input; + return rest.y + 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() + + assertTrue(dtoStmts.filterIsInstance().any { it.right is RequireObjectCoercibleExprDto }) + assertEquals(1, copies.size) + assertTrue(copies[0].throwOnNullishSource) + assertEquals("x", (copies[0].excludedKeys.single() as ConstantDto).value) + + 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) + assertEquals("x", (converted[0].excludedKeys.single() as EtsStringConstant).value) + } + + @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()) + } } diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 4634295ec..2df839ba4 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1486,7 +1486,6 @@ export class ExprLowerer { : { _: "PtrCallExpr", ptr, receiver, method, args }; } - /** `a${x}b` -> chain of string `+` binops. */ private lowerArrayLiteralWithSpread( node: ts.ArrayLiteralExpression, arrayType: TypeDto, @@ -1528,6 +1527,7 @@ export class ExprLowerer { return result; } + /** `a${x}b` -> chain of string `+` binops. */ private lowerTemplate(node: ts.TemplateExpression): ValueDto { let acc: ImmediateDto = constant(node.head.text, STRING_TYPE); for (const span of node.templateSpans) { diff --git a/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts new file mode 100644 index 000000000..4a1ae458b --- /dev/null +++ b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import * as ts from "typescript"; +import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; +import { lower } from "./util"; + +function consumers(source: string, name: string, externals: Record = {}) { + const { file, diagnostics } = lower(source); + expect(diagnostics.messages).toEqual([]); + const ir = executor(JSON.parse(serializeEtsFile(file)), externals); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const native = new Function(...Object.keys(externals), `${js}; return ${name};`)(...Object.values(externals)); + return { ir: (...args: any[]) => ir.call(name, ...args), native, initialize: () => ir.initialize() }; +} + +describe("array iterator behavior after JSON round trip", () => { + for (const source of [ + "function collect(input: any): any[] { return [...input]; }", + "function collect(input: any): any[] { const [...rest] = input; return rest; }", + ]) { + it(`uses the selected iterator and caches next: ${source}`, () => { + const { ir, native } = consumers(source, "collect"); + const input = () => { + let nextReads = 0; + let count = 0; + const next = function (this: any) { + expect(this.marker).toBe(42); + return { value: ++count, done: count > 2 ? "finished" : 0 }; + }; + next.call = () => { throw new Error("A user-defined .call must not be read"); }; + return { + length: 100, + [Symbol.iterator]() { + return { marker: 42, get next() { expect(++nextReads).toBe(1); return next; } }; + }, + }; + }; + + expect(ir(input())).toEqual(native(input())); + expect(ir(input())).toEqual([1, 2]); + }); + + it(`rejects invalid iterator results: ${source}`, () => { + const { ir, native } = consumers(source, "collect"); + const malformed = () => ({ [Symbol.iterator]: () => ({ next: () => 1 }) }); + + expect(() => native(malformed())).toThrow(TypeError); + expect(() => ir(malformed())).toThrow(TypeError); + }); + } + + it("observes changing array length and turns sparse iterator values into stored undefined", () => { + const { ir, native } = consumers("function copy(input: number[]): number[] { return [0, ...input, 9]; }", "copy"); + const input = () => { + const values = [1]; + Object.defineProperty(values, 0, { get() { values[1] = 2; return 1; } }); + return values; + }; + + expect(ir(input())).toEqual(native(input())); + const sparse = new Array(2); + const result = ir(sparse); + expect(result).toEqual(native(sparse)); + expect(Object.hasOwn(result, 1)).toBe(true); + expect(Object.hasOwn(result, 2)).toBe(true); + }); + + it("does not call next after done in a rest binding", () => { + const { ir, native } = consumers("function tail(input: any) { const [a, b, ...rest] = input; return rest; }", "tail"); + const input = () => { + let calls = 0; + return { [Symbol.iterator]: () => ({ next() { if (++calls > 1) throw new Error("next after done"); return { done: true }; } }) }; + }; + + expect(ir(input())).toEqual(native(input())); + }); + + it("skips an omitted binding without reading its value getter", () => { + const { ir, native } = consumers("function tail(input: any) { const [, ...rest] = input; return rest; }", "tail"); + const input = () => { + let calls = 0; + return { [Symbol.iterator]: () => ({ next() { + if (calls++ === 0) return { done: false, get value() { throw new Error("omitted value"); } }; + return { done: true }; + } }) }; + }; + + expect(ir(input())).toEqual(native(input())); + }); + + it("evaluates the source once and drains it before the following element", () => { + const source = "declare function mark(n: number): number; declare function get(): Iterable; function copy() { return [mark(0), ...get(), mark(9)]; }"; + const observed = (consumer: "ir" | "native") => { + const order: any[] = []; + const externals = { + mark(n: number) { order.push(n); return n; }, + get() { + order.push("source"); + return { *[Symbol.iterator]() { order.push("first"); yield 1; order.push("second"); yield 2; order.push("done"); } }; + }, + }; + const functions = consumers(source, "copy", externals); + return { result: functions[consumer](), order }; + }; + + expect(observed("ir")).toEqual(observed("native")); + expect(observed("ir").order).toEqual([0, "source", "first", "second", "done", 9]); + }); + + it("closes on a default initializer throw and preserves the original exception", () => { + const original = new Error("binding default"); + const { ir, native } = consumers( + "declare function fail(): never; function tail(input: any) { const [a = fail(), ...rest] = input; return rest; }", + "tail", { fail: () => { throw original; } }, + ); + const run = (collect: any) => { + let closed = 0; + const input = { [Symbol.iterator]: () => ({ next: () => ({ value: undefined, done: false }), return() { closed++; throw new Error("close"); } }) }; + + expect(() => collect(input)).toThrow(original); + expect(closed).toBe(1); + }; + + run(native); + run(ir); + }); +}); + +describe("default and rest parameter observable behavior", () => { + it("binds earlier parameters before defaults and keeps falsy provided arguments", () => { + const { ir, native } = consumers("function f(a = 3, b = a + 1) { return [a, b]; }", "f"); + + for (const args of [[], [undefined], [0], [null], [false], [0, 7]]) { + expect(ir(...args)).toEqual(native(...args)); + } + }); + + for (const source of ["function f(a = a) { return a; }", "function f(a = b, b = 2) { return a; }"]) { + it(`preserves the parameter temporal dead zone: ${source}`, () => { + const { ir, native } = consumers(source, "f"); + + expect(() => native()).toThrow(ReferenceError); + expect(() => ir()).toThrow(ReferenceError); + expect(ir(7)).toEqual(native(7)); + }); + } + + it("resolves parameter defaults outside the function body's declarations", () => { + const { ir, native, initialize } = consumers("const seed = 7; function f(a = seed) { let seed = 9; return a; }", "f"); + initialize(); + + expect(ir()).toEqual(native()); + expect(ir()).toBe(7); + }); + + it("packs remaining raw arguments in order, including missing normal arguments", () => { + const { ir, native } = consumers("function f(first: number, ...rest: number[]) { return rest; }", "f"); + + for (const args of [[], [1], [1, 2], [1, 2, 3]]) { + expect(ir(...args)).toEqual(native(...args)); + } + }); +}); + +describe("regular expression intrinsic", () => { + it("retains escaped pattern text and flags despite a shadowed RegExp binding", () => { + const { ir, native } = consumers("function matcher(RegExp: any) { return /a\\/b/gi; }", "matcher"); + const lowered = ir(() => { throw new Error("shadowed constructor"); }); + const expected = native(() => { throw new Error("shadowed constructor"); }); + + expect(lowered.source).toEqual(expected.source); + expect(lowered.flags).toEqual(expected.flags); + expect(lowered.test("A/B")).toBe(true); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index 9d24f2812..9d0ed4f3b 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { serializeEtsFile } from "../src/serialize"; import { BasicBlockDto, MethodDto } from "../src/dto/model"; import { IfStmtDto, StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; import { defaultMethod, lower, methodByName } from "./util"; function blocksOf(method: MethodDto): BasicBlockDto[] { diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts index a19a7095d..fcd0e0f7f 100644 --- a/jacodb-ets/ts-frontend/test/closures.spec.ts +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { serializeEtsFile } from "../src/serialize"; import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; import { validateEtsFile } from "../src/validate"; import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; diff --git a/jacodb-ets/ts-frontend/test/lexical-this.spec.ts b/jacodb-ets/ts-frontend/test/lexical-this.spec.ts index fe51a7dc4..5d7ca7b64 100644 --- a/jacodb-ets/ts-frontend/test/lexical-this.spec.ts +++ b/jacodb-ets/ts-frontend/test/lexical-this.spec.ts @@ -5,17 +5,34 @@ import { executeObjectIr } from "./object-runtime"; import { lower, methodByName } from "./util"; describe("lexical arrow receivers", () => { - it("uses the creation receiver when a stored arrow body reads this", () => { - const { file, diagnostics } = lower(` - export function result() { + it("uses the captured receiver before evaluating an arrow parameter default", () => { + const source = ` + export function result(): number { const source = { offset: 11, - install: function(target: any) { target.callback = () => this.offset + 1; }, + install: function(target: any) { target.callback = (value = this.offset) => value + 1; }, }; const target: any = { offset: 100 }; source.install(target); return target.callback(); } + `; + const { file, diagnostics } = lower(source); + const roundTripped = JSON.parse(serializeEtsFile(file)); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(roundTripped, "result")).toBe(12); + + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + expect(new Function("exports", `${javascript}\nreturn exports.result();`)({})).toBe(12); + }); + + it("preserves an outer binding referenced only by a parameter default", () => { + const { file, diagnostics } = lower(` + function make(seed: number) { return (value = seed) => value; } + export function result() { return make(12)(); } `); const roundTripped = JSON.parse(serializeEtsFile(file)); @@ -92,4 +109,22 @@ describe("lexical arrow receivers", () => { expect(nested.every((method) => method.signature.parameters[0].type._ === "LexicalEnvType")).toBe(true); expect(ordinary.every((method) => method.signature.parameters.length === 0)).toBe(true); }); + it("uses the creation receiver when a stored arrow body reads this", () => { + const { file, diagnostics } = lower(` + export function result() { + const source = { + offset: 11, + install: function(target: any) { target.callback = () => this.offset + 1; }, + }; + const target: any = { offset: 100 }; + source.install(target); + return target.callback(); + } + `); + const roundTripped = JSON.parse(serializeEtsFile(file)); + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(roundTripped, "result")).toBe(12); + }); + }); diff --git a/jacodb-ets/ts-frontend/test/lowering.spec.ts b/jacodb-ets/ts-frontend/test/lowering.spec.ts index a08e9111e..b43e124a3 100644 --- a/jacodb-ets/ts-frontend/test/lowering.spec.ts +++ b/jacodb-ets/ts-frontend/test/lowering.spec.ts @@ -26,6 +26,25 @@ function assignmentTo(stmts: StmtDto[], name: string): AssignStmtDto | undefined } describe("straight-line lowering", () => { + it("lowers literal initializers to constants", () => { + const stmts = bodyStmts(`let x = 42; let s = "hi"; let b = true; let n = null; let u = undefined;`); + expect(stmts).toEqual([ + asgn("x", { _: "Constant", value: "42", type: { _: "NumberType" } }), + asgn("s", { _: "Constant", value: "hi", type: { _: "StringType" } }), + asgn("b", { _: "Constant", value: "true", type: { _: "BooleanType" } }), + asgn("n", { _: "Constant", value: "null", type: { _: "NullType" } }), + asgn("u", { _: "Constant", value: "undefined", type: { _: "UndefinedType" } }), + ]); + + function asgn(name: string, right: unknown) { + return { + _: "AssignStmt", + left: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name }) }), + right, + }; + } + }); + it("preserves bigint operations and exact literals through JSON", () => { const source = ` export function increment(x: bigint): bigint { return x + 1n; } @@ -64,25 +83,6 @@ describe("straight-line lowering", () => { expect(concrete).toEqual([2n, 9007199254740993n, -32n]); }); - it("lowers literal initializers to constants", () => { - const stmts = bodyStmts(`let x = 42; let s = "hi"; let b = true; let n = null; let u = undefined;`); - expect(stmts).toEqual([ - asgn("x", { _: "Constant", value: "42", type: { _: "NumberType" } }), - asgn("s", { _: "Constant", value: "hi", type: { _: "StringType" } }), - asgn("b", { _: "Constant", value: "true", type: { _: "BooleanType" } }), - asgn("n", { _: "Constant", value: "null", type: { _: "NullType" } }), - asgn("u", { _: "Constant", value: "undefined", type: { _: "UndefinedType" } }), - ]); - - function asgn(name: string, right: unknown) { - return { - _: "AssignStmt", - left: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name }) }), - right, - }; - } - }); - it("lowers binary expressions with immediate operands", () => { const stmts = bodyStmts("let a = 1; let b = 2; let c = a + b;"); const c = assignmentTo(stmts, "c")!; diff --git a/jacodb-ets/ts-frontend/test/object-computed.spec.ts b/jacodb-ets/ts-frontend/test/object-computed.spec.ts index 8d31d4057..32447fb72 100644 --- a/jacodb-ets/ts-frontend/test/object-computed.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-computed.spec.ts @@ -1,7 +1,7 @@ import { runInNewContext } from "node:vm"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; -import { DefineDataPropertyStmtDto } from "../src/dto/stmts"; +import { AssignStmtDto, DefineDataPropertyStmtDto } from "../src/dto/stmts"; import { compile, lower, methodByName } from "./util"; import { executeObjectIr } from "./object-runtime"; @@ -66,6 +66,58 @@ describe("computed object keys", () => { expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "read")).toBe(8); }); + it("reads a computed destructuring key from the original source", () => { + const source = ` + export function pick(input: Record, key: string): number { + const { [key]: value } = input; + return value; + } + `; + const compiled = compile(source); + expect(compiled.program.getSemanticDiagnostics(compiled.sourceFile)).toEqual([]); + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: "value" }), + right: expect.objectContaining({ _: "PropertyRef", key: expect.objectContaining({ _: "Local" }) }), + })); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: expect.stringMatching(/^%/) }), + right: expect.objectContaining({ _: "ToPropertyKeyExpr", arg: expect.objectContaining({ name: "key" }) }), + })); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.pick({ x: 3 }, "x")`)).toBe(3); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "pick", [{ x: 3 }, "x"])).toBe(3); + }); + + it("preserves the destructuring source when a computed key mutates its binding", () => { + const source = ` + let input = { x: 3 }; + let calls = 0; + function change(): string { calls++; input = { x: 9 }; return "x"; } + export function pick(): number { + const { [change()]: value } = input; + return value + calls; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); + const callIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "change"); + const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); + const read = stmts[readIndex] as AssignStmtDto; + + expect(callIndex).toBeGreaterThanOrEqual(0); + expect(readIndex).toBeGreaterThan(callIndex); + expect(read.right).toMatchObject({ _: "PropertyRef", instance: { _: "Local", name: expect.stringMatching(/^%/) } }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.pick()`)).toBe(4); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "pick")).toBe(4); + }); it("converts an object key before evaluating the property value", () => { const source = ` export function read(key: any, state: { phase: string }): number { @@ -87,6 +139,23 @@ describe("computed object keys", () => { return exports.read(key, state); })()`)).toBe(7); }); + + it("rejects nullish binding sources before converting the key", () => { + const source = `export function pick(input: any, key: any) { const { [key]: value } = input; return value; }`; + const { file, diagnostics } = lower(source); + let conversions = 0; + const key = { toString() { conversions++; return "x"; } }; + + expect(diagnostics.messages).toEqual([]); + expect(() => executeObjectIr(JSON.parse(JSON.stringify(file)), "pick", [null, key])).toThrow(TypeError); + expect(conversions).toBe(0); + expect(evaluate(source, `(() => { + let conversions = 0; + try { exports.pick(null, { toString() { conversions++; return "x"; } }); } catch (error) {} + return conversions; + })()`)).toBe(0); + }); + it("preserves the receiver before evaluating a computed access key", () => { const source = `export function read(key: string): number { let input = { x: 3 }; @@ -98,4 +167,5 @@ describe("computed object keys", () => { 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-copy.spec.ts b/jacodb-ets/ts-frontend/test/object-copy.spec.ts index 3fb60fe90..d1477ab1c 100644 --- a/jacodb-ets/ts-frontend/test/object-copy.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-copy.spec.ts @@ -1,6 +1,7 @@ import { runInNewContext } from "node:vm"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; +import { AssignStmtDto } from "../src/dto/stmts"; import { compile, lower, methodByName } from "./util"; import { executeObjectIr } from "./object-runtime"; @@ -62,6 +63,77 @@ describe("object property copying", () => { })()`)).toBe(71); }); + it("builds object rest by excluding bound keys", () => { + const source = ` + export function remainder(input: { x: number; y: number }): number { + const { x, ...rest } = input; + return rest.y; + } + `; + const compiled = compile(source); + expect(compiled.program.getSemanticDiagnostics(compiled.sourceFile)).toEqual([]); + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "remainder").body!.cfg.blocks.flatMap((block) => block.stmts); + const xReadIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PropertyRef" && stmt.right.key._ === "Constant" && stmt.right.key.value === "x"); + const copyIndex = stmts.findIndex((stmt) => stmt._ === "CopyDataPropertiesStmt"); + + expect(xReadIndex).toBeGreaterThanOrEqual(0); + expect(copyIndex).toBeGreaterThan(xReadIndex); + expect(stmts[copyIndex]).toMatchObject({ + _: "CopyDataPropertiesStmt", + excludedKeys: [{ _: "Constant", value: "x" }], + throwOnNullishSource: true, + }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.remainder({ x: 1, y: 2 })`)).toBe(2); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "remainder", [{ x: 1, y: 2 }])).toBe(2); + }); + + it("reuses one evaluated computed key for both binding and rest exclusion", () => { + const source = ` + export function remainder(input: Record, key: string): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "remainder").body!.cfg.blocks.flatMap((block) => block.stmts); + const read = stmts.find((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" + && stmt.right._ === "PropertyRef"); + const copy = stmts.find((stmt) => stmt._ === "CopyDataPropertiesStmt"); + + expect(read?.right).toMatchObject({ _: "PropertyRef", key: { _: "Local" } }); + expect(copy).toMatchObject({ + _: "CopyDataPropertiesStmt", + excludedKeys: [(read?.right as { key: unknown }).key], + }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.remainder({ x: 3, y: 2 }, "x")`)).toBe(5); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "remainder", [{ x: 3, y: 2 }, "x"])).toBe(5); + }); + it("normalizes a computed key once before reading and excluding it from rest", () => { + const source = ` + export function remainder(input: any, key: any): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + `; + const { file, diagnostics } = lower(source); + let conversions = 0; + const key = { toString() { conversions++; return conversions === 1 ? "x" : "y"; } }; + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "remainder", [{ x: 3, y: 2 }, key])).toBe(5); + expect(conversions).toBe(1); + expect(evaluate(source, `(() => { + let conversions = 0; + const key = { toString() { conversions++; return conversions === 1 ? "x" : "y"; } }; + return exports.remainder({ x: 3, y: 2 }, key) * 10 + conversions; + })()`)).toBe(51); + }); + 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); @@ -87,13 +159,21 @@ describe("object property copying", () => { 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; }`; + + it("skips nullish spread but rejects nullish rest", () => { + const source = ` + export function spread(input: any): number { return { ...input, x: 7 }.x; } + export function rest(input: any): any { const { ...result } = input; return result; } + `; const { file, diagnostics } = lower(source); + const serialized = JSON.parse(JSON.stringify(file)); 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(executeObjectIr(serialized, "spread", [null])).toBe(7); + expect(executeObjectIr(serialized, "spread", [undefined])).toBe(7); + expect(() => executeObjectIr(serialized, "rest", [undefined])).toThrow(TypeError); expect(evaluate(source, `exports.spread(null)`)).toBe(7); + expect(evaluate(source, `(() => { try { exports.rest(undefined); } catch (error) { return error instanceof TypeError; } })()`)).toBe(true); }); + }); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.spec.ts b/jacodb-ets/ts-frontend/test/object-runtime.spec.ts index bd6b444e3..4a585e41c 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.spec.ts @@ -26,14 +26,14 @@ function rewriteCaptures(value: unknown, rewrite: (type: LexicalEnvTypeDto) => v return changed; } -const BODY_CAPTURE = ` - function make(seed: number) { return function() { return seed; }; } +const DEFAULT_CAPTURE = ` + function make(seed: number) { return (value = seed) => value; } 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 file = lowerSerialized(DEFAULT_CAPTURE); const lifted = file.classes.flatMap((clazz) => clazz.methods) .find((method) => method.signature.parameters[0]?.type._ === "LexicalEnvType")!; const environmentType = lifted.signature.parameters[0].type; @@ -46,7 +46,7 @@ describe("object test interpreter closure environments", () => { }); it("rejects a closure reference omitted from its base's declared slots", () => { - const file = lowerSerialized(BODY_CAPTURE); + const file = lowerSerialized(DEFAULT_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] : []) diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts index fb265b41f..475b0d31d 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -90,7 +90,6 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] if (source === null || source === undefined) throw new TypeError("nullish object binding"); return source; } - case "StaticCallExpr": return invoke(lookup(value.method), value.args.map(read), undefined); case "InstanceCallExpr": { const object = read(value.instance); diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 4a3ed8da2..5016214f3 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { executor } from "./execute"; import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; import { syntaxKindName } from "../src/lowering/diagnostics"; import * as ts from "typescript"; import { validateEtsFile } from "../src/validate"; @@ -12,9 +14,9 @@ function allStmts(method: { body?: { cfg: { blocks: { stmts: StmtDto[] }[] } } } describe("unsupported loop bindings do not break the whole file", () => { // `lower()` throws on unplaced labels via finalize(), so merely lowering is the assertion. const cases: Record = { - "array pattern with rest": "declare const xs: any[]; for (const [a, ...rest] of xs) { console.log(a); }", + "array pattern with rest": "declare const xs: any[]; for (const [[a], ...rest] of xs) { console.log(a); }", "member expression target": "declare const xs: any[]; const obj: any = {}; for (obj.x of xs) { console.log(obj.x); }", - "rest in for-in": "declare const o: any; for (const [a, ...rest] in o) { console.log(a); }", + "rest in for-in": "declare const o: any; for (const [[a], ...rest] in o) { console.log(a); }", }; for (const [name, source] of Object.entries(cases)) { @@ -94,6 +96,203 @@ describe("pattern parameters", () => { }); }); +describe("default parameter initializers", () => { + it("captures outer values referenced only by a closure's parameter default", () => { + const { file, diagnostics } = lower("function wrap(seed: number) { return (value = seed) => value; }"); + const closure = methodByName(file, "%AM0$wrap"); + + expect(diagnostics.messages).toEqual([]); + expect(allStmts(closure).some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ClosureFieldRef" + && stmt.right.fieldName === "seed")).toBe(true); + }); + + it("keeps delayed temporal-dead-zone captures explicitly unsupported", () => { + const { file, diagnostics } = lower("function f(a = () => b, b = a()) { return b; }"); + + expect(diagnostics.messages.some((message) => message.includes("runtime temporal-dead-zone cell"))).toBe(true); + expect(allStmts(methodByName(file, "f")).some((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "UnsupportedValue")).toBe(true); + }); + + it("guards defaults after their own argument reads in source order through JSON", () => { + const source = ` + const order: string[] = []; + function record(label: string, value: number): number { order.push(label); return value; } + function value(x = record("x", 3), y = record("y", 4)): number { return x + y; } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const body = methodByName(roundTrip, "value").body!; + const stmts = allStmts({ body }); + const argumentReads = stmts.flatMap((stmt, index) => + stmt._ === "AssignStmt" && stmt.right._ === "ParameterRef" + ? [{ index, parameterIndex: stmt.right.index }] + : [], + ); + const defaults = stmts.flatMap((stmt, index) => + stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "record" + ? [{ index, label: stmt.right.args[0] }] + : [], + ); + const conditions = stmts.filter((stmt) => stmt._ === "IfStmt").map((stmt) => stmt.condition); + + expect(diagnostics.messages).toEqual([]); + expect(argumentReads.map((read) => read.parameterIndex)).toEqual([0, 1]); + expect(defaults.map((call) => call.label)).toMatchObject([ + { _: "Constant", value: "x" }, + { _: "Constant", value: "y" }, + ]); + expect(argumentReads[0].index).toBeLessThan(defaults[0].index); + expect(defaults[0].index).toBeLessThan(argumentReads[1].index); + expect(argumentReads[1].index).toBeLessThan(defaults[1].index); + expect(conditions).toEqual([ + expect.objectContaining({ _: "ConditionExpr", op: "===", right: { _: "Constant", value: "undefined", type: { _: "UndefinedType" } } }), + expect.objectContaining({ _: "ConditionExpr", op: "===", right: { _: "Constant", value: "undefined", type: { _: "UndefinedType" } } }), + ]); + + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const concrete = new Function(`${js}\nreturn { + missing: value(), + undefinedValue: value(undefined, undefined), + zero: value(0, 1), + order, + };`)() as { missing: number; undefinedValue: number; zero: number; order: string[] }; + expect(concrete).toEqual({ missing: 7, undefinedValue: 7, zero: 1, order: ["x", "y", "x", "y"] }); + const ir = executor(roundTrip); + ir.initialize(); + expect([ir.call("value"), ir.call("value", undefined, undefined), ir.call("value", 0, 1)]).toEqual([7, 7, 1]); + }); +}); + +describe("rest parameter calls", () => { + it("preserves the rest signature and array-valued argument slot through JSON", () => { + const source = ` + function count(...values: number[]): number { return values.length; } + function sample(): number { return count() + count(1, 2); } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const method = methodByName(roundTrip, "count"); + const stmts = allStmts(method); + + expect(diagnostics.messages).toEqual([]); + expect(method.signature.parameters).toMatchObject([ + { name: "values", type: { _: "ArrayType" }, isRest: true }, + ]); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: "values", type: expect.objectContaining({ _: "ArrayType" }) }), + right: expect.objectContaining({ _: "ParameterRef", index: 0, type: expect.objectContaining({ _: "ArrayType" }) }), + })); + const callArgs = allStmts(methodByName(roundTrip, "sample")) + .filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + && stmt.right.method.name === "count") + .map((stmt) => stmt.right.args.length); + expect(callArgs).toEqual([0, 2]); + + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const concrete = new Function(`${js}\nreturn [count(), count(1, 2), sample()];`)() as number[]; + expect(concrete).toEqual([0, 2, 2]); + const ir = executor(roundTrip); + expect([ir.call("count"), ir.call("count", 1, 2), ir.call("sample")]).toEqual(concrete); + }); +}); + +describe("array rest bindings", () => { + it("copies the tail into a fresh array for empty, short, and long inputs through JSON", () => { + const source = ` + function tail(input: number[]): number[] { + const [first, ...rest] = input; + return rest; + } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = allStmts(methodByName(roundTrip, "tail")); + const allocations = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewArrayExpr"); + const iteratorCalls = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceCallExpr" + && stmt.right.method.name === "Symbol.iterator"); + const tailStores = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.left._ === "ArrayRef" + && stmt.left.array._ === "Local" && stmt.left.array.name !== "input"); + + expect(diagnostics.messages).toEqual([]); + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ right: { _: "NewArrayExpr", size: { _: "Constant", value: "0" } } }); + expect(iteratorCalls).toHaveLength(1); + expect(tailStores).toHaveLength(1); + expect(stmts.filter((stmt) => stmt._ === "IfStmt").length).toBeGreaterThan(2); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); + + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const concrete = new Function(`${js}\nreturn [tail([]), tail([1]), tail([1, 2, 3])];`)() as number[][]; + expect(concrete).toEqual([[], [], [2, 3]]); + const ir = executor(roundTrip); + expect([ir.call("tail", []), ir.call("tail", [1]), ir.call("tail", [1, 2, 3])]).toEqual(concrete); + }); +}); + +describe("array spread literals", () => { + it("copies a variable-length input in order through JSON", () => { + const source = ` + function copy(input: number[]): number[] { return [0, ...input, 9]; } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = allStmts(methodByName(roundTrip, "copy")); + const allocations = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewArrayExpr"); + const lengthRead = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceFieldRef" + && stmt.right.field.name === "length"); + const sourceRead = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef" + && stmt.right.array._ === "Local" && stmt.right.array.name !== "input"); + + expect(diagnostics.messages).toEqual([]); + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ right: { _: "NewArrayExpr", size: { _: "Constant", value: "0" } } }); + expect(lengthRead).toBeUndefined(); + expect(sourceRead).toBeUndefined(); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceCallExpr" + && stmt.right.method.name === "Symbol.iterator")).toBe(true); + expect(stmts.some((stmt) => stmt._ === "UnsupportedValue")).toBe(false); + + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const concrete = new Function(`${js}\nreturn [copy([]), copy([1]), copy([1, 2, 3])];`)() as number[][]; + expect(concrete).toEqual([[0, 9], [0, 1, 9], [0, 1, 2, 3, 9]]); + const ir = executor(roundTrip); + expect([ir.call("copy", []), ir.call("copy", [1]), ir.call("copy", [1, 2, 3])]).toEqual(concrete); + }); +}); + +describe("regular-expression literals", () => { + it("retains pattern, flags, allocation, and test call through JSON", () => { + const source = `function containsX(value: string): boolean { return /x+/gi.test(value); }`; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = allStmts(methodByName(roundTrip, "containsX")); + const allocations = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"); + const calls = stmts.filter((stmt) => stmt._ === "AssignStmt" + && (stmt.right._ === "InstanceCallExpr" || stmt.right._ === "PtrCallExpr")); + + expect(diagnostics.messages).toEqual([]); + expect(allocations).toHaveLength(1); + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ + right: { + method: { name: "constructor" }, + args: [{ _: "Constant", value: "x+" }, { _: "Constant", value: "gi" }], + }, + }); + expect(calls[1]).toMatchObject({ + right: { _: "PtrCallExpr", method: { name: "test" }, receiver: { _: "Local" }, ptr: { _: "Local" } }, + }); + + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const concrete = new Function(`${js}\nreturn [containsX("XX"), containsX("ab")];`)() as boolean[]; + expect(concrete).toEqual([true, false]); + const ir = executor(roundTrip); + expect([ir.call("containsX", "XX"), ir.call("containsX", "ab")]).toEqual(concrete); + }); +}); + describe("source local names", () => { it("keeps source parameters out of validator-reserved local prefixes", () => { const { file } = lower("function f(_tmp0: number) { return _tmp0 + 1; }"); diff --git a/jacodb-ets/ts-frontend/test/spread-calls.spec.ts b/jacodb-ets/ts-frontend/test/spread-calls.spec.ts new file mode 100644 index 000000000..75434391c --- /dev/null +++ b/jacodb-ets/ts-frontend/test/spread-calls.spec.ts @@ -0,0 +1,216 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; +import { lower, methodByName, singleBlockStmts } from "./util"; + +function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { + const expansion = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr"); + const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" + && (stmt.right._ === "StaticCallExpr" || stmt.right._ === "InstanceCallExpr") + && stmt.right.method.name === methodName); + + expect(reads).toHaveLength(2); + expect(expansion).toMatchObject({ right: { _: "SpreadExpansionExpr", expectedCount: 2 } }); + for (const read of reads) { + expect(read).toMatchObject({ right: { array: expansion?._ === "AssignStmt" ? expansion.left : undefined } }); + } + expect(reads.map((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef" + && stmt.right.index._ === "Constant" ? stmt.right.index.value : "invalid")).toEqual(["0", "1"]); + expect(call).toMatchObject({ + _: "AssignStmt", + right: { + args: reads.map((stmt) => stmt._ === "AssignStmt" ? stmt.left : undefined), + }, + }); +} + +function runTypeScript(source: string, expression: string): unknown { + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + return new Function("exports", `${js}\nreturn ${expression};`)({}); +} + +describe("fixed-size spread arguments", () => { + it("captures a computed method value and receiver before expanding its spread", () => { + const source = ` + class Sum { + bias = 10; + add(a: number, b: number): number { return this.bias + a + b; } + } + export function value(receiver: Sum, pair: [number, number]): number { + return receiver["add"](...pair); + } + `; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(JSON.parse(serializeEtsFile(file)), "value")); + const property = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); + const expansionIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr"); + + expect(property).toBeDefined(); + expect(expansionIndex).toBeGreaterThan(stmts.indexOf(property!)); + expect(call).toMatchObject({ right: { + ptr: property?._ === "AssignStmt" ? property.left : undefined, + receiver: property?._ === "AssignStmt" && property.right._ === "PropertyRef" ? property.right.instance : undefined, + args: expect.any(Array), + } }); + expect(call?._ === "AssignStmt" && call.right._ === "PtrCallExpr" ? call.right.args : []).toHaveLength(2); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.value(new Sum(), [2, 3])")).toBe(15); + }); + + it("preserves runtime iteration for a tuple with a replaced iterator", () => { + const source = ` + function add(a: number, b: number): number { return a + b; } + export function sum(pair: [number, number]): number { return add(...pair); } + export function concrete(): number { + const pair: [number, number] = [2, 3]; + pair[Symbol.iterator] = function* () { yield 7; yield 8; }; + return sum(pair); + } + `; + const { file } = lower(source); + const stmts = singleBlockStmts(methodByName(JSON.parse(serializeEtsFile(file)), "sum")); + + assertSpreadArgumentOrder(stmts, "add"); + expect(runTypeScript(source, "exports.concrete()")).toBe(15); + }); + + it("expands a tuple in an ordinary call and preserves JSON argument order", () => { + const source = ` + function add(a: number, b: number): number { return a + b; } + export function sum(pair: [number, number]): number { return add(...pair); } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(roundTrip, "sum")); + + assertSpreadArgumentOrder(stmts, "add"); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.sum([2, 3])")).toBe(5); + }); + + it("expands a tuple in a constructor call and preserves JSON argument order", () => { + const source = ` + class Point { + y: number; + constructor(x: number, y: number) { this.y = y; } + } + export function make(pair: [number, number]): number { return new Point(...pair).y; } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(roundTrip, "make")); + + assertSpreadArgumentOrder(stmts, "constructor"); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.make([2, 3])")).toBe(3); + }); + + it("evaluates arguments around a spread in source order", () => { + const source = ` + let events: string[] = []; + function first(): number { events.push("first"); return 1; } + function pair(): [number, number] { events.push("pair"); return [2, 3]; } + function last(): number { events.push("last"); return 4; } + function combine(a: number, b: number, c: number, d: number): number { + return a * 1000 + b * 100 + c * 10 + d; + } + export function value(): number { return combine(first(), ...pair(), last()); } + export function trace(): string { return events.join(","); } + `; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(file, "value")); + const calls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index, args: stmt.right.args }] : []); + const reads = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" + && stmt.right._ === "ArrayRef" ? [index] : []); + + expect(calls.map((call) => call.name)).toEqual(["first", "pair", "last", "combine"]); + expect(reads).toHaveLength(2); + expect(calls[1]!.index).toBeLessThan(reads[0]); + expect(reads[1]).toBeLessThan(calls[2]!.index); + expect(calls[3]!.args).toHaveLength(4); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "{ value: exports.value(), trace: exports.trace() }")).toEqual({ + value: 1234, + trace: "first,pair,last", + }); + }); + + it("rejects an unbounded spread without calling the target", () => { + const source = ` + function add(a: number, b: number): number { return a + b; } + class Point { constructor(public x: number, public y: number) {} } + export function sum(values: number[]): number { return add(...values); } + export function make(values: number[]): Point { return new Point(...values); } + `; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(file, "sum")); + + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + && stmt.right.method.name === "add")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" + && stmt.right.kindName === "CallExpression")).toBe(true); + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("spread argument has no statically known length"), + ])); + + const constructorStmts = singleBlockStmts(methodByName(file, "make")); + expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false); + expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" + && stmt.right.kindName === "NewExpression")).toBe(true); + }); +}); + +describe("computed constructor arrays", () => { + it("keeps optional computed object constructors guarded and uses property semantics", () => { + const source = ` + class A { x = 1; } + export function make(holder: { Ctor: typeof A } | undefined): number { + return new (holder?.["Ctor"])().x; + } + `; + const { file, diagnostics } = lower(source); + const method = methodByName(JSON.parse(serializeEtsFile(file)), "make"); + const stmts = method.body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(method.body!.cfg.blocks.length).toBeGreaterThan(1); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr" + && stmt.right.constructorValue?._ === "Local")).toBe(true); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.make({ Ctor: A })")).toBe(1); + }); + + it("reads an indexed constructor once before allocation and preserves JSON", () => { + const source = ` + class A { kind = "A"; } + class B { kind = "B"; } + let reads = 0; + function constructors(): Array { reads++; return [A, B]; } + function index(): number { reads++; return 1; } + export function make(): string { return new (constructors()[index()])().kind; } + export function count(): number { return reads; } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(roundTrip, "make")); + const functionCalls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index }] : []); + const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); + const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr" + && stmt.right.constructorValue?._ === "Local"); + + expect(functionCalls.map((call) => call.name)).toEqual(["constructors", "index"]); + expect(functionCalls[1]!.index).toBeLessThan(readIndex); + expect(readIndex).toBeLessThan(allocationIndex); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "{ kind: exports.make(), count: exports.count() }")).toEqual({ kind: "B", count: 2 }); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts index 7e72bb9ff..2aef33dc9 100644 --- a/jacodb-ets/ts-frontend/test/types.spec.ts +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -342,8 +342,8 @@ describe("typeOfNode (inference)", () => { expect(inferredOf("let x = 42;")).toEqual({ _: "NumberType" }); expect(inferredOf(`let x = "s";`)).toEqual({ _: "StringType" }); expect(inferredOf("let x = true;")).toEqual({ _: "BooleanType" }); - expect(inferredOf("let x = 1n;")).toEqual({ _: "BigIntType" }); expect(inferredOf("let x = Symbol();")).toEqual({ _: "SymbolType" }); + expect(inferredOf("let x = 1n;")).toEqual({ _: "BigIntType" }); }); it("keeps literal types for const-declarations", () => { From 103b98281b7acacc56903bff8d79d80f54feef41 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 10 Oct 2026 20:40:36 +0300 Subject: [PATCH 2/2] Align for-await integration assertions with iterator closing --- .../org/jacodb/ets/test/EtsTsFrontendTest.kt | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) 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 bef752106..be2b23284 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 @@ -253,19 +253,79 @@ class EtsTsFrontendTest { .methods.single { it.signature.name == name } val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() val calls = assignments.mapNotNull { it.right as? PtrCallExprDto } - val awaits = assignments.mapNotNull { it.right as? AwaitExprDto } + val awaitAssignments = assignments.filter { it.right is AwaitExprDto } val next = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "next" } + val iterator = (next.right as PtrCallExprDto).receiver + val nextAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == next.left } + val nextValueReads = assignments.filter { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "value" && field.instance == nextAwait.left + } == true + } + val syncValueAwait = awaitAssignments.single { + (it.right as AwaitExprDto).arg == nextValueReads.first().left + } + val returnLookup = assignments.single { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "return" && field.instance == iterator + } == true + } + val close = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "return" } + val closeCall = close.right as PtrCallExprDto + val closeValueRead = assignments.single { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "value" && field.instance == close.left + } == true + } + val asyncCloseAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == close.left } + val syncCloseAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == closeValueRead.left } + val missingReturnAwait = awaitAssignments.single { + ((it.right as AwaitExprDto).arg as? org.jacodb.ets.dto.ConstantDto)?.value == "undefined" + } assertTrue(calls.any { it.method.name == "Symbol.asyncIterator" && it.receiver != null }) assertTrue(calls.any { it.method.name == "Symbol.iterator" && it.receiver != null }) - assertEquals(2, awaits.size) - assertTrue(awaits.any { it.arg == next.left }) + assertTrue(iterator != null) + assertEquals(2, nextValueReads.size) + assertTrue(nextValueReads.all { it.left == syncValueAwait.left }) + assertEquals(returnLookup.left, closeCall.ptr) + assertEquals(iterator, closeCall.receiver) + assertTrue(assignments.any { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "done" && field.instance == close.left + } == true + }) + assertEquals( + setOf(nextAwait, syncValueAwait, asyncCloseAwait, syncCloseAwait, missingReturnAwait), + awaitAssignments.toSet(), + ) + assertEquals(5, awaitAssignments.size) assertTrue(assignments.none { it.right is RawValueDto }) val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } .methods.single { it.name == name } - assertEquals(2, modelMethod.cfg.stmts.filterIsInstance() - .count { it.rhv is EtsAwaitExpr }) + val modelAssignments = modelMethod.cfg.stmts.filterIsInstance() + val modelAwaits = modelAssignments.mapNotNull { it.rhv as? EtsAwaitExpr } + val modelNext = modelAssignments.single { + (it.rhv as? org.jacodb.ets.model.EtsPtrCallExpr)?.callee?.name == "next" + } + val modelClose = modelAssignments.single { + (it.rhv as? org.jacodb.ets.model.EtsPtrCallExpr)?.callee?.name == "return" + } + + assertEquals(awaitAssignments.size, modelAwaits.size) + assertEquals( + (iterator as org.jacodb.ets.dto.LocalDto).name, + (modelNext.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver?.name, + ) + assertEquals( + (modelNext.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver, + (modelClose.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver, + ) + for (dtoAwait in listOf(nextAwait, syncValueAwait, asyncCloseAwait, syncCloseAwait)) { + val argument = (dtoAwait.right as AwaitExprDto).arg as org.jacodb.ets.dto.LocalDto + assertTrue(modelAwaits.any { it.arg.name == argument.name }) + } } }