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 7f07d52d7..74e2e4349 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 @@ -22,6 +22,7 @@ import mu.KotlinLogging import org.jacodb.ets.model.EtsDefineDataPropertyStmt import org.jacodb.ets.model.EtsPropertyRef import org.jacodb.ets.model.EtsToPropertyKeyExpr +import org.jacodb.ets.model.EtsToNumericExpr import org.jacodb.ets.toArrayType import org.jacodb.ets.model.BasicBlock import org.jacodb.ets.model.EtsAddExpr @@ -394,6 +395,11 @@ class EtsMethodBuilder( arg = ensureOneAddress(arg.toEtsEntity()), ) + is ToNumericExprDto -> EtsToNumericExpr( + arg = ensureOneAddress(arg.toEtsEntity()), + type = type.toEtsType(), + ) + is PropertyRefDto -> EtsPropertyRef( instance = (instance as LocalDto).toEtsLocal(), key = ensureOneAddress(key.toEtsEntity()), diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt index 184afeb8c..e150674f6 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt @@ -160,6 +160,13 @@ data class ToPropertyKeyExprDto( override val type: TypeDto get() = UnknownTypeDto } +@Serializable +@SerialName("ToNumericExpr") +data class ToNumericExprDto( + val arg: ValueDto, + override val type: TypeDto, +) : ExprDto + @Serializable @SerialName("PropertyRef") data class PropertyRefDto( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt index 70739cfe3..17c6de9fc 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt @@ -39,6 +39,10 @@ interface EtsExpr : EtsEntity { if (this is Default) return defaultVisit(expr) error("Cannot handle ${expr::class.java.simpleName}: $expr") } + fun visit(expr: EtsToNumericExpr): R { + if (this is Default) return defaultVisit(expr) + error("Cannot handle ${expr::class.java.simpleName}: $expr") + } fun visit(expr: EtsRequireObjectCoercibleExpr): R { if (this is Default) return defaultVisit(expr) error("Cannot handle ${expr::class.java.simpleName}: $expr") @@ -105,6 +109,7 @@ interface EtsExpr : EtsEntity { override fun visit(expr: EtsNewClassExpr): R = defaultVisit(expr) override fun visit(expr: EtsSpreadExpansionExpr): R = defaultVisit(expr) override fun visit(expr: EtsToPropertyKeyExpr): R = defaultVisit(expr) + override fun visit(expr: EtsToNumericExpr): R = defaultVisit(expr) override fun visit(expr: EtsRequireObjectCoercibleExpr): R = defaultVisit(expr) override fun visit(expr: EtsNewArrayExpr): R = defaultVisit(expr) override fun visit(expr: EtsCastExpr): R = defaultVisit(expr) @@ -213,6 +218,16 @@ data class EtsToPropertyKeyExpr(val arg: EtsValue) : EtsExpr { override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) } +/** ECMAScript ToNumeric: ToPrimitive with a number hint, then Number or unchanged BigInt. */ +data class EtsToNumericExpr( + val arg: EtsValue, + override val type: EtsType, +) : EtsExpr { + override fun toString(): String = "toNumeric($arg)" + + override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) +} + /** Evaluation of a class expression creates a fresh constructor for [signature]. */ data class EtsNewClassExpr( val signature: EtsClassSignature, 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 a5bd6e398..85e5a8563 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 @@ -90,6 +90,7 @@ import org.jacodb.ets.model.EtsSubExpr import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsThrowStmt import org.jacodb.ets.model.EtsToPropertyKeyExpr +import org.jacodb.ets.model.EtsToNumericExpr import org.jacodb.ets.model.EtsTypeOfExpr import org.jacodb.ets.model.EtsUnaryPlusExpr import org.jacodb.ets.model.EtsUndefinedConstant @@ -197,6 +198,9 @@ private object EntityGetOperands : EtsEntity.Visitor> { override fun visit(expr: EtsToPropertyKeyExpr): Sequence = sequenceOf(expr.arg) + override fun visit(expr: EtsToNumericExpr): Sequence = + sequenceOf(expr.arg) + override fun visit(value: EtsPropertyRef): Sequence = sequenceOf(value.instance, value.key) 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 36b7d8d89..a0e609854 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 @@ -90,6 +90,7 @@ import org.jacodb.ets.model.EtsSubExpr import org.jacodb.ets.model.EtsThis import org.jacodb.ets.model.EtsThrowStmt import org.jacodb.ets.model.EtsToPropertyKeyExpr +import org.jacodb.ets.model.EtsToNumericExpr import org.jacodb.ets.model.EtsTypeOfExpr import org.jacodb.ets.model.EtsUnaryPlusExpr import org.jacodb.ets.model.EtsUndefinedConstant @@ -265,6 +266,11 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor expr.arg.accept(this) } + final override fun visit(expr: EtsToNumericExpr) { + handle(expr) + expr.arg.accept(this) + } + final override fun visit(value: EtsPropertyRef) { handle(value) value.instance.accept(this) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsNumericUpdateTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsNumericUpdateTest.kt new file mode 100644 index 000000000..3eeb4983f --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsNumericUpdateTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

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

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

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.BigIntTypeDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.LocalDto +import org.jacodb.ets.dto.NumberTypeDto +import org.jacodb.ets.dto.ReturnStmtDto +import org.jacodb.ets.dto.ToNumericExprDto +import org.jacodb.ets.dto.UnionTypeDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsBigIntType +import org.jacodb.ets.model.EtsEntity +import org.jacodb.ets.model.EtsExpr +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.model.EtsToNumericExpr +import org.jacodb.ets.model.EtsUnionType +import org.jacodb.ets.utils.AbstractHandler +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.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EtsTsNumericUpdateTest { + @Test + fun `numeric old values survive production JSON decoding and model traversal`() { + val source = """ + export function numberLocal(x: number): number { return x++; } + export function bigintLocal(x: bigint): bigint { return x--; } + export function computed(object: any, key: any): unknown { return object[key()]++; } + export function array(values: (number | bigint)[]): unknown { return values[0]--; } + """.trimIndent() + val directory = createTempDirectory("ts-numeric-update-test") + val input = directory.resolve("test.ts") + input.writeText(source) + + val output = generateEtsIR( + input, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + assertTrue(output.exists(), message = "production frontend did not produce output") + val frontendDto = EtsFileDto.loadFromJson(output.readText()) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + val dtoClass = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + val modelClass = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + val numericDto = UnionTypeDto(types = listOf(NumberTypeDto, BigIntTypeDto)) + val numericModel = EtsUnionType(types = listOf(EtsNumberType, EtsBigIntType)) + val expectedDtoTypes = mapOf( + "numberLocal" to NumberTypeDto, + "bigintLocal" to BigIntTypeDto, + "computed" to numericDto, + "array" to numericDto, + ) + val expectedModelTypes = mapOf( + "numberLocal" to EtsNumberType, + "bigintLocal" to EtsBigIntType, + "computed" to numericModel, + "array" to numericModel, + ) + + for ((name, expectedType) in expectedDtoTypes) { + val dtoStmts = dtoClass.methods.single { it.signature.name == name } + .body!!.cfg.blocks.flatMap { it.stmts } + val conversion = dtoStmts.filterIsInstance().single { it.right is ToNumericExprDto } + val numeric = conversion.right as ToNumericExprDto + val result = dtoStmts.filterIsInstance().single().arg as LocalDto + + assertEquals(expected = expectedType, actual = numeric.type) + assertEquals(expected = (conversion.left as LocalDto).name, actual = result.name) + + val modelStmts = modelClass.methods.single { it.name == name }.cfg.stmts + val modelNumeric = modelStmts.filterIsInstance() + .mapNotNull { it.rhv as? EtsToNumericExpr }.single() + assertEquals(expected = expectedModelTypes.getValue(name), actual = modelNumeric.type) + assertEquals(expected = listOf(modelNumeric.arg), actual = modelNumeric.getOperands().toList()) + + val visited = mutableListOf() + val handler = object : AbstractHandler() { + override fun handle(value: EtsEntity) { + visited += value + } + + override fun handle(stmt: EtsStmt) = Unit + } + modelNumeric.accept(handler) + assertEquals(expected = listOf(modelNumeric, modelNumeric.arg), actual = visited) + + val defaultVisitor = object : EtsExpr.Visitor.Default { + override fun defaultVisit(expr: EtsExpr): String = expr.javaClass.simpleName + } + assertEquals(expected = "EtsToNumericExpr", actual = modelNumeric.accept(defaultVisitor)) + } + } +} diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index 833f9cae4..cb3ab33c8 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -47,6 +47,7 @@ export type ExprDto = | NewClassExprDto | SpreadExpansionExprDto | ToPropertyKeyExprDto + | ToNumericExprDto | RequireObjectCoercibleExprDto | NewArrayExprDto | DeleteExprDto @@ -176,6 +177,13 @@ export interface ToPropertyKeyExprDto { arg: ImmediateDto; } +/** ECMAScript ToNumeric; uses a number hint and preserves a BigInt primitive. */ +export interface ToNumericExprDto { + readonly _: "ToNumericExpr"; + arg: ImmediateDto; + type: TypeDto; +} + /** Generic property Get/Set, with an already converted runtime key. */ export interface PropertyRefDto { readonly _: "PropertyRef"; diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 21f9f80da..8677c02a0 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -986,19 +986,34 @@ export class ExprLowerer { returnOld: boolean, ): ValueDto { const op: UnaryOp = operator === ts.SyntaxKind.PlusPlusToken ? "++" : "--"; - const target = this.lowerLValue(operand); + let target = this.lowerLValue(operand); + + // Get and ToNumeric can replace source bindings. Keep the selected + // receiver and raw index for Put, without converting the index early. + if (target._ === "InstanceFieldRef" || target._ === "PropertyRef") { + target = { ...target, instance: this.m.snapshotToLocal(target.instance, target.instance.type) }; + } else if (target._ === "ArrayRef") { + const array = target.array; + const index = target.index; + target = { + ...target, + array: array._ === "Local" ? this.m.snapshotToLocal(array, array.type) : array, + index: index._ === "Local" ? this.m.snapshotToLocal(index, index.type) : index, + }; + } + + const rawValue = target._ === "Local" + ? target + : this.materialize(this.propertyReferenceForOperation(target), lvalueType(target)); + const numericType = toNumericType(lvalueType(target)); + const oldValue = this.materialize({ _: "ToNumericExpr", arg: rawValue, type: numericType }, numericType); if (target._ === "Local") { - // Postfix needs a copy of the old value BEFORE the update. - const saved = returnOld ? this.materialize(target, target.type) : undefined; - this.m.cfg.emit({ _: "AssignStmt", left: target, right: { _: "UnopExpr", op, arg: target } }); - return saved ?? target; + this.m.cfg.emit({ _: "AssignStmt", left: target, right: { _: "UnopExpr", op, arg: oldValue } }); + return returnOld ? oldValue : target; } - // Field/array target: load old, compute updated, store back. - // %old := ref; %new := %old ++; ref := %new - const oldValue = this.materialize(this.propertyReferenceForOperation(target), lvalueType(target)); - const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, lvalueType(target)); + const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, numericType); this.m.cfg.emit({ _: "AssignStmt", left: this.propertyReferenceForOperation(target), right: updated }); return returnOld ? oldValue : updated; } @@ -2112,6 +2127,30 @@ function lvalueType(target: LValueDto): TypeDto { } } +/** Type hint for the converted primitive; opaque objects can produce either numeric kind. */ +function toNumericType(type: TypeDto): TypeDto { + switch (type._) { + case "BigIntType": + return BIGINT_TYPE; + case "NumberType": + case "StringType": + case "BooleanType": + case "NullType": + case "UndefinedType": + case "LiteralType": + return NUMBER_TYPE; + case "AliasType": + return toNumericType(type.originalType); + case "UnionType": { + const numericTypes = type.types.map(toNumericType); + if (numericTypes.every((numeric) => numeric._ === "NumberType")) return NUMBER_TYPE; + if (numericTypes.every((numeric) => numeric._ === "BigIntType")) return BIGINT_TYPE; + break; + } + } + return { _: "UnionType", types: [NUMBER_TYPE, BIGINT_TYPE] }; +} + export function arrayElementType(array: Extract): TypeDto { if (array.dimensions <= 1) { return array.elementType; diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index c61c5bb6a..781a9761a 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -35,6 +35,7 @@ const EXPR_KINDS = new Set([ "NewClassExpr", "SpreadExpansionExpr", "ToPropertyKeyExpr", + "ToNumericExpr", "RequireObjectCoercibleExpr", "NewArrayExpr", "DeleteExpr", @@ -422,6 +423,7 @@ export function valueOperands(value: ValueDto): ValueDto[] { case "TypeOfExpr": case "CastExpr": case "ToPropertyKeyExpr": + case "ToNumericExpr": case "RequireObjectCoercibleExpr": case "UnopExpr": return [value.arg]; diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts index ab5225246..723fa8b1b 100644 --- a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -29,6 +29,7 @@ async function execute(method: MethodDto, input: unknown): Promise { case "UndefinedType": return undefined; case "NullType": return null; case "NumberType": return Number(value.value); + case "BigIntType": return BigInt(value.value); case "BooleanType": return value.value === "true"; default: return value.value; } @@ -38,9 +39,14 @@ async function execute(method: MethodDto, input: unknown): Promise { case "InstanceFieldRef": return read(value.instance)[value.field.name]; case "PropertyRef": return read(value.instance)[read(value.key)]; case "TypeOfExpr": return typeof read(value.arg); - case "UnopExpr": - if (value.op !== "!") throw new Error(`Unexpected unary op ${value.op}`); - return !read(value.arg); + case "ToNumericExpr": return -(-read(value.arg)); + case "UnopExpr": { + if (value.op === "!") return !read(value.arg); + let numeric = read(value.arg); + if (value.op === "++") return ++numeric; + if (value.op === "--") return --numeric; + throw new Error(`Unexpected unary op ${value.op}`); + } case "BinopExpr": case "ConditionExpr": { const left = read(value.left); diff --git a/jacodb-ets/ts-frontend/test/execute.ts b/jacodb-ets/ts-frontend/test/execute.ts index fdf01ecc4..89db6ac34 100644 --- a/jacodb-ets/ts-frontend/test/execute.ts +++ b/jacodb-ets/ts-frontend/test/execute.ts @@ -24,6 +24,7 @@ export function executor(file: EtsFileDto, externals: Record = {}) case "UndefinedType": return undefined; case "NullType": return null; case "NumberType": return Number(v.value); + case "BigIntType": return BigInt(v.value); case "BooleanType": return v.value === "true"; default: return v.value; } @@ -40,10 +41,15 @@ export function executor(file: EtsFileDto, externals: Record = {}) } case "TypeOfExpr": return typeof value(v.arg); case "CastExpr": return value(v.arg); - case "UnopExpr": + case "ToNumericExpr": return -(-value(v.arg)); + case "UnopExpr": { if (v.op === "!") return !value(v.arg); if (v.op === "-") return -value(v.arg); + let numeric = value(v.arg); + if (v.op === "++") return ++numeric; + if (v.op === "--") return --numeric; throw new Error(`Unsupported unary op: ${v.op}`); + } case "BinopExpr": case "ConditionExpr": { const left = value(v.left); diff --git a/jacodb-ets/ts-frontend/test/lowering.spec.ts b/jacodb-ets/ts-frontend/test/lowering.spec.ts index b43e124a3..91ff07aee 100644 --- a/jacodb-ets/ts-frontend/test/lowering.spec.ts +++ b/jacodb-ets/ts-frontend/test/lowering.spec.ts @@ -475,13 +475,19 @@ describe("straight-line lowering", () => { const stmts = bodyStmts(source); const all = assigns(stmts); - // postfix: %old := c.f; %new := %old ++; c.f := %new; post := %old + // Postfix returns the numeric old value after loading the field once. const postAssign = assignmentTo(stmts, "post")!; const postSource = (postAssign.right as { name: string }).name; const oldLoad = all.find((a) => a.left._ === "Local" && a.left.name === postSource)!; - expect(oldLoad.right._).toBe("InstanceFieldRef"); // holds the OLD value + expect(oldLoad.right._).toBe("ToNumericExpr"); + if (oldLoad.right._ !== "ToNumericExpr" || oldLoad.right.arg._ !== "Local") { + throw new Error("expected a converted numeric old value"); + } + const rawSource = oldLoad.right.arg.name; + const rawLoad = all.find((a) => a.left._ === "Local" && a.left.name === rawSource)!; + expect(rawLoad.right._).toBe("InstanceFieldRef"); - // prefix: %old := c.f; %new := %old ++; c.f := %new; pre := %new + // Prefix returns the updated value. const preAssign = assignmentTo(stmts, "pre")!; const preSource = (preAssign.right as { name: string }).name; const newCompute = all.find((a) => a.left._ === "Local" && a.left.name === preSource)!; diff --git a/jacodb-ets/ts-frontend/test/numeric-update-reference.spec.ts b/jacodb-ets/ts-frontend/test/numeric-update-reference.spec.ts new file mode 100644 index 000000000..9eaa0df46 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/numeric-update-reference.spec.ts @@ -0,0 +1,189 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { beforeAll, describe, expect, it } from "vitest"; +import { EtsFileDto } from "../src/dto/model"; +import { serializeEtsFile } from "../src/serialize"; +import { executeObjectIr } from "./object-runtime"; +import { lower } from "./util"; + +const updates = [ + ["postIncrement", (operand: string) => `${operand}++`], + ["postDecrement", (operand: string) => `${operand}--`], + ["preIncrement", (operand: string) => `++${operand}`], + ["preDecrement", (operand: string) => `--${operand}`], +] as const; +const shapes = ["dot", "array", "tuple"] as const; +type Shape = typeof shapes[number]; +type Effect = "getter" | "numeric" | "key"; +type Sample = "number" | "bigint" | "negativeZero" | "throws"; + +const source = [ + ...shapes.flatMap((shape) => updates.map(([name, update]) => { + const type = shape === "array" ? "(number | bigint)[]" + : shape === "tuple" ? "[number | bigint, number | bigint]" : "any"; + const operand = shape === "dot" ? "receiver.m" : "receiver[key]"; + + return `export function ${shape}_${name}(input: ${type}, replacement: ${type}, initialKey: any, + replacementKey: any, prepare: any, state: any): unknown { + let receiver: ${type} = input; + let key: any = initialKey; + prepare(function() { receiver = replacement; key = replacementKey; state.changed = true; }); + const result = ${update(operand)}; + state.receiver = receiver; + state.key = key; + return result; + }`; + })), + ...updates.map(([name, update]) => `export function local_${name}(input: any, prepare: any, state: any): unknown { + let value: any = input; + prepare(function() { value = 999; }); + const result = ${update("value")}; + state.stored = value; + return result; + }`), +].join("\n"); + +let serialized: EtsFileDto; +let native: Record unknown>; + +beforeAll(() => { + const { file, diagnostics } = lower(source); + serialized = JSON.parse(serializeEtsFile(file)); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + expect(diagnostics.messages).toEqual([]); + native = runInNewContext(`${javascript}\nexports`, { exports: {} }); +}); + +function observe( + execute: (...args: unknown[]) => unknown, + shape: Shape, + effect: Effect, + sample: Sample, + throwingEffect?: "getter" | "putKey", +) { + const events: string[] = []; + const state: { changed?: boolean; receiver?: unknown; key?: unknown } = {}; + let mutate = () => { throw new Error("mutation callback was not prepared"); }; + const numeric = { + [Symbol.toPrimitive](hint: string) { + events.push(`numeric:${hint}`); + if (effect === "numeric") mutate(); + if (sample === "throws") throw new RangeError("numeric"); + return sample === "bigint" ? 3n : sample === "negativeZero" ? -0 : 3; + }, + }; + const original: any = shape === "dot" ? { m: numeric, other: 100 } : [numeric, 100]; + const replacement: any = shape === "dot" ? { m: 500, other: 600 } : [500, 600]; + const input = new Proxy(original, { + get(target, name) { + events.push(`get:${String(name)}`); + if (effect === "getter") mutate(); + if (throwingEffect === "getter") throw new RangeError("getter"); + return Reflect.get(target, name); + }, + set(target, name, value) { + events.push(`put:${String(name)}`); + return Reflect.set(target, name, value); + }, + }); + let conversions = 0; + const initialKey = { + [Symbol.toPrimitive](hint: string) { + events.push(`key:${hint}`); + conversions++; + if (effect === "key") mutate(); + if (throwingEffect === "putKey" && conversions === 2) throw new RangeError("key"); + return conversions === 1 ? "0" : "1"; + }, + }; + const replacementKey = "0"; + const prepare = (callback: () => void) => { mutate = callback; }; + const encode = (value: unknown) => value === numeric ? "original numeric object" : value; + let result: unknown; + let error: string | undefined; + + try { + result = execute(input, replacement, initialKey, replacementKey, prepare, state); + } catch (failure) { + error = (failure as Error).name; + } + + return { + result, + error, + events, + changed: state.changed, + receiverIsReplacement: state.receiver === replacement, + keyIsReplacement: state.key === replacementKey, + original: shape === "dot" ? [encode(original.m), original.other] : original.map(encode), + replacement: shape === "dot" ? [replacement.m, replacement.other] : [...replacement], + }; +} + +const cases = shapes.flatMap((shape) => updates.flatMap(([name]) => { + const effects: Effect[] = shape === "dot" ? ["getter", "numeric"] : ["getter", "numeric", "key"]; + return effects.map((effect) => [shape, name, effect] as const); +})); + +describe("numeric update reference capture", () => { + it.each(cases)("%s %s retains its reference through %s mutation", (shape, name, effect) => { + const method = `${shape}_${name}`; + + for (const sample of ["number", "bigint", "negativeZero", "throws"] as const) { + const expected = observe(native[method], shape, effect, sample); + const actual = observe((...args) => executeObjectIr(serialized, method, args), shape, effect, sample); + + expect(actual, sample).toEqual(expected); + expect(actual.replacement, sample).toEqual([500, 600]); + if (expected.error !== undefined) { + expect(actual.events.some((event) => event.startsWith("put:")), sample).toBe(false); + expect(actual.original, sample).toEqual(["original numeric object", 100]); + } else { + expect(actual.changed, sample).toBe(true); + expect(actual.receiverIsReplacement, sample).toBe(true); + expect(actual.keyIsReplacement, sample).toBe(true); + } + } + }); + + it.each(["array", "tuple"] as const)("%s stops before Put if the saved key's second conversion throws", (shape) => { + const method = `${shape}_postIncrement`; + const expected = observe(native[method], shape, "numeric", "number", "putKey"); + const actual = observe((...args) => executeObjectIr(serialized, method, args), shape, "numeric", "number", "putKey"); + + expect(expected.error).toBe("RangeError"); + expect(expected.events).toEqual(["key:string", "get:0", "numeric:number", "key:string"]); + expect(actual).toEqual(expected); + }); + + it("stops before ToNumeric and Put when a getter replaces the receiver and throws", () => { + const method = "dot_postIncrement"; + const expected = observe(native[method], "dot", "getter", "number", "getter"); + const actual = observe((...args) => executeObjectIr(serialized, method, args), "dot", "getter", "number", "getter"); + + expect(expected.error).toBe("RangeError"); + expect(expected.events).toEqual(["get:m"]); + expect(actual).toEqual(expected); + }); + + it.each(updates.map(([name]) => [name] as const))("local %s writes the original binding after coercion replaces its value", (name) => { + const method = `local_${name}`; + const observeLocal = (execute: (...args: unknown[]) => unknown) => { + const state: { stored?: unknown } = {}; + let mutate = () => { throw new Error("mutation callback was not prepared"); }; + const input = { valueOf() { mutate(); return 3; } }; + const prepare = (callback: () => void) => { mutate = callback; }; + + return { result: execute(input, prepare, state), stored: state.stored }; + }; + + const expected = observeLocal(native[method]); + const actual = observeLocal((...args) => executeObjectIr(serialized, method, args)); + + expect(actual).toEqual(expected); + expect(actual.stored).toBe(name.endsWith("Increment") ? 4 : 2); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/numeric-update.spec.ts b/jacodb-ets/ts-frontend/test/numeric-update.spec.ts new file mode 100644 index 000000000..4f8a8a25d --- /dev/null +++ b/jacodb-ets/ts-frontend/test/numeric-update.spec.ts @@ -0,0 +1,221 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { beforeAll, describe, expect, it } from "vitest"; +import { EtsFileDto } from "../src/dto/model"; +import { serializeEtsFile } from "../src/serialize"; +import { executeObjectIr } from "./object-runtime"; +import { executor } from "./execute"; +import { lower } from "./util"; + +const updates = [ + ["postIncrement", (operand: string) => `${operand}++`], + ["postDecrement", (operand: string) => `${operand}--`], + ["preIncrement", (operand: string) => `++${operand}`], + ["preDecrement", (operand: string) => `--${operand}`], +] as const; +const locations = ["local", "numberLocal", "property", "dot", "array"] as const; + +const samples: { name: string; create: (events: string[]) => unknown }[] = [ + { name: "undefined", create: () => undefined }, + { name: "null", create: () => null }, + { name: "boolean", create: () => true }, + { name: "numeric string", create: () => "3" }, + { name: "empty string", create: () => "" }, + { name: "nonnumeric string", create: () => "bad" }, + { name: "negative zero", create: () => -0 }, + { name: "NaN", create: () => NaN }, + { name: "Infinity", create: () => Infinity }, + { name: "negative Infinity", create: () => -Infinity }, + { name: "fraction", create: () => 3.5 }, + { name: "large number", create: () => 2 ** 53 }, + { name: "subnormal", create: () => Number.MIN_VALUE }, + { name: "bigint", create: () => 3n }, + { name: "large bigint", create: () => 9007199254740993n }, + { name: "boxed number", create: () => Object(3) }, + { name: "boxed negative zero", create: () => Object(-0) }, + { name: "boxed bigint", create: () => Object(3n) }, + { name: "symbol", create: () => Symbol("value") }, + { name: "number primitive", create: (events) => ({ + [Symbol.toPrimitive](hint: string) { events.push(`numeric:${hint}`); return 3; }, + }) }, + { name: "bigint primitive", create: (events) => ({ + [Symbol.toPrimitive](hint: string) { events.push(`numeric:${hint}`); return 3n; }, + }) }, + { name: "symbol primitive", create: (events) => ({ + [Symbol.toPrimitive](hint: string) { events.push(`numeric:${hint}`); return Symbol("value"); }, + }) }, + { name: "nonprimitive conversion", create: (events) => ({ + [Symbol.toPrimitive](hint: string) { events.push(`numeric:${hint}`); return {}; }, + }) }, + { name: "throwing conversion", create: (events) => ({ + [Symbol.toPrimitive](hint: string) { events.push(`numeric:${hint}`); throw new RangeError("numeric"); }, + }) }, + { name: "valueOf string", create: (events) => ({ + valueOf() { events.push("valueOf"); return "3"; }, + }) }, + { name: "toString fallback", create: (events) => ({ + valueOf() { events.push("valueOf"); return {}; }, + toString() { events.push("toString"); return "3"; }, + }) }, +]; + +const source = [ + ...locations.flatMap((location) => updates.map(([name, update]) => { + if (location === "local" || location === "numberLocal") { + const type = location === "numberLocal" ? "number" : "any"; + return `export function ${location}_${name}(value: ${type}, state: any): unknown { + const result = ${update("value")}; + state.stored = value; + return result; + }`; + } + const type = location === "array" ? "(number | bigint)[]" : "any"; + const operand = location === "dot" ? "object.m" : "object[key()]"; + return `export function ${location}_${name}(object: ${type}, key: any): unknown { + return ${update(operand)}; + }`; + })), + `export function capturedPost(state: any): unknown { + let value: any = { valueOf: function() { value = 9; return 3; } }; + const result = value++; + state.stored = value; + return result; + }`, + `export function caughtNumeric(value: any, state: any): unknown { + try { + const result = value++; + state.stored = value; + return result; + } catch { + state.original = value; + return 7; + } + }`, +].join("\n"); + +let serialized: EtsFileDto; +let native: Record unknown>; + +beforeAll(() => { + const { file, diagnostics } = lower(source); + serialized = JSON.parse(serializeEtsFile(file)); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + expect(diagnostics.messages).toEqual([]); + native = runInNewContext(`${javascript}\nexports`, { exports: {} }); +}); + +function observe( + execute: (...args: unknown[]) => unknown, + location: typeof locations[number], + sample: typeof samples[number], + changingKey = false, + throwOnPutKey = false, +): { result?: unknown; error?: string; stored?: unknown; unchanged?: boolean; events: string[] } { + const events: string[] = []; + const input = sample.create(events); + const state: { stored?: unknown } = {}; + const data = location === "array" ? [input, 100] : { m: input, other: 100 }; + let conversions = 0; + const keyObject = { + [Symbol.toPrimitive](hint: string) { + events.push(`key:${hint}`); + conversions++; + if (throwOnPutKey && conversions === 2) throw new RangeError("put key"); + return location === "array" + ? (changingKey && conversions === 2 ? "1" : "0") + : (changingKey && conversions === 2 ? "other" : "m"); + }, + }; + const key = () => { events.push("key expression"); return keyObject; }; + const object = new Proxy(data, { + get(target, name) { events.push(`get:${String(name)}`); return Reflect.get(target, name); }, + set(target, name, value) { events.push(`put:${String(name)}`); return Reflect.set(target, name, value); }, + }); + const local = location === "local" || location === "numberLocal"; + const stored = () => local ? state.stored : Reflect.get(data, + location === "array" ? (changingKey ? "1" : "0") : (changingKey ? "other" : "m")); + const initialStored = stored(); + + try { + const result = local ? execute(input, state) : execute(object, key); + return { result, stored: stored(), events }; + } catch (error) { + return { error: (error as Error).name, stored: stored(), unchanged: Object.is(stored(), initialStored), events }; + } +} + +describe("numeric update results and effects", () => { + it.each(locations.flatMap((location) => updates.map(([name]) => [location, name] as const)))( + "%s %s matches native numeric conversion, result and store", (location, name) => { + const method = `${location}_${name}`; + + for (const sample of samples) { + const expected = observe(native[method], location, sample); + const actual = observe((...args) => executeObjectIr(serialized, method, args), location, sample); + + // A throwing conversion leaves the original object, which belongs to a separate fixture. + if (expected.error !== undefined) { + expect(actual.error, sample.name).toBe(expected.error); + expect(actual.events, sample.name).toEqual(expected.events); + expect(actual.unchanged, sample.name).toBe(true); + expect(actual.events.some((event) => event.startsWith("put:")), sample.name).toBe(false); + } else { + expect(actual.result, sample.name).toBe(expected.result); + expect(actual.stored, sample.name).toBe(expected.stored); + expect(actual.events, sample.name).toEqual(expected.events); + } + } + }, + ); + + it.each(["postIncrement", "postDecrement"])("preserves both key conversions around %s", (name) => { + const sample = samples.find((candidate) => candidate.name === "number primitive")!; + const method = `property_${name}`; + const expected = observe(native[method], "property", sample, true); + const actual = observe((...args) => executeObjectIr(serialized, method, args), "property", sample, true); + + expect(expected.events).toEqual(["key expression", "key:string", "get:m", "numeric:number", "key:string", "put:other"]); + expect(expected.result).toBe(3); + expect(actual).toEqual(expected); + }); + + it("does not Put when the second key conversion throws after ToNumeric", () => { + const sample = samples.find((candidate) => candidate.name === "number primitive")!; + const expected = observe(native.property_postIncrement, "property", sample, false, true); + const actual = observe((...args) => executeObjectIr(serialized, "property_postIncrement", args), "property", sample, false, true); + + expect(expected.error).toBe("RangeError"); + expect(actual.error).toBe(expected.error); + expect(actual.events).toEqual(["key expression", "key:string", "get:m", "numeric:number", "key:string"]); + }); + + it("keeps a converted local when its valueOf replaces the captured binding", () => { + for (const execute of [native.capturedPost, (...args: unknown[]) => executeObjectIr(serialized, "capturedPost", args)]) { + const state: { stored?: unknown } = {}; + + expect(execute(state)).toBe(3); + expect(state.stored).toBe(4); + } + }); + + it("routes a throwing ToNumeric to catch before updating the local", () => { + const ir = executor(serialized); + + for (const execute of [native.caughtNumeric, (...args: unknown[]) => ir.call("caughtNumeric", ...args)]) { + const events: string[] = []; + const input = { [Symbol.toPrimitive](hint: string) { + events.push(hint); + throw new RangeError("numeric"); + } }; + const state: { stored?: unknown; original?: unknown } = {}; + + expect(execute(input, state)).toBe(7); + expect(state.original).toBe(input); + expect(state.stored).toBeUndefined(); + expect(events).toEqual(["number"]); + } + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-runtime.ts b/jacodb-ets/ts-frontend/test/object-runtime.ts index 475b0d31d..bd8e2169f 100644 --- a/jacodb-ets/ts-frontend/test/object-runtime.ts +++ b/jacodb-ets/ts-frontend/test/object-runtime.ts @@ -71,6 +71,7 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] switch (value.type._) { case "StringType": return value.value; case "NumberType": return Number(value.value); + case "BigIntType": return BigInt(value.value); case "BooleanType": return value.value === "true"; case "NullType": return null; case "UndefinedType": return undefined; @@ -85,6 +86,7 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] case "PropertyRef": return read(value.instance)[read(value.key)]; case "ArrayRef": return read(value.array)[read(value.index)]; case "ToPropertyKeyExpr": return Reflect.ownKeys({ [read(value.arg)]: 0 })[0]; + case "ToNumericExpr": return -(-read(value.arg)); case "RequireObjectCoercibleExpr": { const source = read(value.arg); if (source === null || source === undefined) throw new TypeError("nullish object binding"); @@ -104,10 +106,14 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] value.args.map(read), ); case "CastExpr": return read(value.arg); - case "UnopExpr": + case "UnopExpr": { if (value.op === "+") return +read(value.arg); - if (value.op === "++") return Number(read(value.arg)) + 1; + if (value.op === "-") return -read(value.arg); + let numeric = read(value.arg); + if (value.op === "++") return ++numeric; + if (value.op === "--") return --numeric; throw new Error(`unknown unary operator ${value.op}`); + } case "ConditionExpr": switch (value.op) { case "===": return read(value.left) === read(value.right); @@ -132,6 +138,7 @@ export function executeObjectIr(file: EtsFileDto, name: string, args: unknown[] case "ClosureFieldRef": captureEnvironment(left)[left.fieldName] = value; return; case "InstanceFieldRef": read(left.instance)[left.field.name] = value; return; case "PropertyRef": read(left.instance)[read(left.key)] = value; return; + case "ArrayRef": read(left.array)[read(left.index)] = value; return; default: throw new Error(`unsupported object test assignment ${left._}`); } }; diff --git a/jacodb-ets/ts-frontend/test/validate.spec.ts b/jacodb-ets/ts-frontend/test/validate.spec.ts index 5b0c51d2b..cbde98e62 100644 --- a/jacodb-ets/ts-frontend/test/validate.spec.ts +++ b/jacodb-ets/ts-frontend/test/validate.spec.ts @@ -50,6 +50,26 @@ function violations(body: BodyDto): string[] { } describe("validateEtsFile", () => { + it("checks the operand of ToNumeric instead of accepting it as a raw fallback", () => { + const body = bodyWithBlocks([ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { _: "AssignStmt", left: local("result"), right: { + _: "ToNumericExpr", arg: local("ghost"), type: NUMBER_TYPE, + } }, + { _: "ReturnVoidStmt" }, + ], + }, + ], ["result"]); + + expect(violations(body)).toEqual([ + expect.stringContaining("local 'ghost' is not declared in body.locals"), + ]); + }); + it("accepts an empty cfg", () => { expect(violations(bodyWithBlocks([]))).toEqual([]); });