From beef938a23e9dcd95e9f33792e3b93e7250a5ca0 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:26:03 +0300 Subject: [PATCH 01/33] Support parameterized EtsIR model families --- .../call/TsBuiltInUnknownCallModels.kt | 11 +++++++++- .../machine/call/TsEtsIrUnknownCallModel.kt | 22 ++++++++++++++++++- .../intrinsic/TsBuiltInUnknownCallModel.kt | 5 +++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt index 935d768c3..5db429b4d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltInUnknownCallModels.kt @@ -1,15 +1,24 @@ package org.usvm.machine.call import org.usvm.machine.call.intrinsic.TsBuiltInUnknownCallModel +import org.usvm.machine.call.intrinsic.TsBuiltInUnknownCallModelFamily /** Discovers built-in model objects from the sealed hierarchy. */ object TsBuiltInUnknownCallModels { private val models by lazy { - TsBuiltInUnknownCallModel::class.sealedSubclasses.map { modelClass -> + val individualModels = TsBuiltInUnknownCallModel::class.sealedSubclasses.map { modelClass -> requireNotNull(modelClass.objectInstance) { "Built-in semantic model must be an object: ${modelClass.qualifiedName}" } } + val modelFamilies = TsBuiltInUnknownCallModelFamily::class.sealedSubclasses.flatMap { familyClass -> + val family = requireNotNull(familyClass.objectInstance) { + "Built-in semantic model family must be an object: ${familyClass.qualifiedName}" + } + family.models + } + + individualModels + modelFamilies } private val allModels by lazy { TsUnknownCallModelCatalog(models) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt index c0b11f8be..304ee1633 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt @@ -111,11 +111,17 @@ class TsEtsIrUnknownCallModel( override val target: TsUnknownCallTarget, val artifact: TsEtsIrUnknownCallModelArtifact, val domainGuard: TsEtsIrUnknownCallModelDomainGuard = TsEtsIrUnknownCallModelDomainGuard.ALWAYS, + val inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, ) : TsUnknownCallModel, TsMachineLocalUnknownCallModel { override val additionalSceneFiles: List = listOf(artifact.file) override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { - val inputs = call.resolvedInputs() ?: return null + val resolvedInputs = call.resolvedInputs() ?: return null + val inputs = inputAdapter.adapt( + state = state, + call = call, + resolvedInputs = resolvedInputs, + ) ?: return null if (inputs.size != artifact.entryPoint.parameters.size) { return null } @@ -154,10 +160,24 @@ class TsEtsIrUnknownCallModel( target = target, artifact = materializedArtifact, domainGuard = domainGuard, + inputAdapter = inputAdapter, ) } } +/** Adapts resolved call inputs to the parameters of a TypeScript model entry point. */ +fun interface TsEtsIrUnknownCallModelInputAdapter { + fun adapt( + state: TsState, + call: TsUnknownCall, + resolvedInputs: List>, + ): List>? + + companion object { + val IDENTITY = TsEtsIrUnknownCallModelInputAdapter { _, _, resolvedInputs -> resolvedInputs } + } +} + /** Builds the symbolic input guard for one TypeScript model body. */ fun interface TsEtsIrUnknownCallModelDomainGuard { fun evaluate( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt index ce29d46e5..7edace476 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsBuiltInUnknownCallModel.kt @@ -4,3 +4,8 @@ import org.usvm.machine.call.TsUnknownCallModel /** Implement as an object in this package; the sealed hierarchy registers every built-in automatically. */ internal sealed interface TsBuiltInUnknownCallModel : TsUnknownCallModel + +/** Supplies a parameterized family of built-in semantic models from one object. */ +internal sealed interface TsBuiltInUnknownCallModelFamily { + val models: List +} From c48f468772766b375e93504d26d2e574b2fc4b35 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:30:52 +0300 Subject: [PATCH 02/33] Add primary numeric intrinsic models --- .../intrinsic/TsNumericIntrinsicModels.kt | 237 ++++++++++++++++++ .../usvm/machine/expr/CallApproximations.kt | 143 ++++++++++- .../call/TsUnknownCallModelCatalogTest.kt | 16 +- 3 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt new file mode 100644 index 000000000..091dadfca --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt @@ -0,0 +1,237 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.expr.KFpRoundingMode +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsLocal +import org.usvm.UExpr +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.state.TsState + +internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily { + const val MATH_ABS_ID: String = "ts.math.abs" + const val MATH_CEIL_ID: String = "ts.math.ceil" + const val MATH_MAX_ID: String = "ts.math.max" + const val MATH_MIN_ID: String = "ts.math.min" + const val MATH_ROUND_ID: String = "ts.math.round" + const val NUMBER_IS_INTEGER_ID: String = "ts.number.isInteger" + + override val models: List = listOf( + NumericIntrinsicModel(MATH_ABS_ID, "abs") { state, call -> + unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } + }, + NumericIntrinsicModel(MATH_CEIL_ID, "ceil") { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), + value = value, + ) + } + } + }, + NumericIntrinsicModel(MATH_MAX_ID, "max") { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.NEGATIVE_INFINITY, + combine = state::mathMax, + ) + }, + NumericIntrinsicModel(MATH_MIN_ID, "min") { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.POSITIVE_INFINITY, + combine = state::mathMin, + ) + }, + NumericIntrinsicModel(MATH_ROUND_ID, "round") { state, call -> + unaryMathCall(state, call, state::mathRound) + }, + NumericIntrinsicModel(NUMBER_IS_INTEGER_ID, "isInteger", ::numberIsInteger), + ) +} + +private class NumericIntrinsicModel( + override val id: String, + methodName: String, + private val implementation: (TsState, TsUnknownCall) -> TsUnknownCallModelExecution?, +) : TsUnknownCallModel { + override val target = numericTarget(methodName) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? = + implementation(state, call) +} + +private fun numberIsInteger(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Number") || call.arguments.size != 1) { + return null + } + val value = call.arguments.single().resolved ?: return null + val result = with(state.ctx) { + if (value.isFakeObject()) { + val type = value.getFakeType(state.memory) + mkAnd(type.fpTypeExpr, state.isInteger(value.extractFp(state.memory))) + } else if (value.sort == fp64Sort) { + state.isInteger(value.asExpr(fp64Sort)) + } else { + falseExpr + } + } + + return state.normalExecution(result) +} + +private fun numericTarget(methodName: String) = TsUnknownCallTarget( + methodName = methodName, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, +) + +private fun unaryMathCall( + state: TsState, + call: TsUnknownCall, + operation: (UExpr) -> UExpr, +): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Math") || call.arguments.size != 1) { + return null + } + val argument = call.arguments.single().resolved ?: return null + if (argument.sort != state.ctx.fp64Sort) { + return null + } + + val result = operation(argument.asExpr(state.ctx.fp64Sort)) + return state.normalExecution(result) +} + +private fun variadicMathCall( + state: TsState, + call: TsUnknownCall, + identity: Double, + combine: (UExpr, UExpr) -> UExpr, +): TsUnknownCallModelExecution? { + if (!call.hasGlobalOwner("Math")) { + return null + } + val arguments = call.arguments.map { argument -> + val value = argument.resolved ?: return null + if (value.sort != state.ctx.fp64Sort) { + return null + } + + value.asExpr(state.ctx.fp64Sort) + } + val result = arguments.fold(state.ctx.mkFp(identity, state.ctx.fp64Sort), combine) + + return state.normalExecution(result) +} + +private fun TsUnknownCall.hasGlobalOwner(expectedName: String): Boolean { + val owner = receiver?.source as? EtsLocal ?: return false + return owner.name == expectedName +} + +private fun TsState.normalExecution(result: UExpr<*>): TsUnknownCallModelExecution = with(ctx) { + val successor = TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + + TsUnknownCallModelExecution(successors = listOf(successor)) +} + +private fun TsState.isInteger(value: UExpr) = with(ctx) { + val truncated = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = value, + ) + + mkAnd( + mkFpIsNaNExpr(value).not(), + mkFpIsInfiniteExpr(value).not(), + mkFpEqualExpr(value, truncated), + ) +} + +private fun TsState.mathMin( + left: UExpr, + right: UExpr, +): UExpr = with(ctx) { + val zero = mkFp(0.0, fp64Sort) + val negativeZero = mkFp(-0.0, fp64Sort) + val eitherNegative = mkOr(mkFpIsNegativeExpr(left), mkFpIsNegativeExpr(right)) + val signedZero = mkIte(eitherNegative, negativeZero, zero) + val bothZero = mkAnd(mkFpIsZeroExpr(left), mkFpIsZeroExpr(right)) + + mkIte( + mkFpIsNaNExpr(left), + left, + mkIte( + mkFpIsNaNExpr(right), + right, + mkIte( + mkFpLessExpr(left, right), + left, + mkIte(mkFpLessExpr(right, left), right, mkIte(bothZero, signedZero, left)), + ), + ), + ) +} + +private fun TsState.mathMax( + left: UExpr, + right: UExpr, +): UExpr = with(ctx) { + val zero = mkFp(0.0, fp64Sort) + val negativeZero = mkFp(-0.0, fp64Sort) + val eitherPositive = mkOr(mkFpIsPositiveExpr(left), mkFpIsPositiveExpr(right)) + val signedZero = mkIte(eitherPositive, zero, negativeZero) + val bothZero = mkAnd(mkFpIsZeroExpr(left), mkFpIsZeroExpr(right)) + + mkIte( + mkFpIsNaNExpr(left), + left, + mkIte( + mkFpIsNaNExpr(right), + right, + mkIte( + mkFpGreaterExpr(left, right), + left, + mkIte(mkFpGreaterExpr(right, left), right, mkIte(bothZero, signedZero, left)), + ), + ), + ) +} + +private fun TsState.mathRound(value: UExpr): UExpr = with(ctx) { + val roundingMode = fpRoundingModeSortDefaultValue() + val floor = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative), + value = value, + ) + val fraction = mkFpSubExpr(roundingMode, value, floor) + val rounded = mkIte( + mkFpLessExpr(fraction, mkFp(0.5, fp64Sort)), + floor, + mkFpAddExpr(roundingMode, floor, mkFp(1.0, fp64Sort)), + ) + val signedRounded = mkIte( + mkAnd(mkFpIsNegativeExpr(value), mkFpIsZeroExpr(rounded)), + mkFp(-0.0, fp64Sort), + rounded, + ) + val preserveInput = mkOr( + mkFpIsNaNExpr(value), + mkFpIsInfiniteExpr(value), + mkFpIsZeroExpr(value), + ) + + mkIte(preserveInput, value, signedRounded) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 385cd3867..969a90c25 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -30,6 +30,7 @@ import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved import org.usvm.machine.interpreter.setResolvedValue import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.lastStmt import org.usvm.machine.state.newStmt import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.machine.types.mkFakeValue @@ -53,10 +54,15 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( return from(mkUndefinedValue()) } - // Handle `Number.isNaN()` calls + // Handle `Number` calls. if (expr.instance.name == "Number") { - if (expr.callee.name == "isNaN") { - return from(handleNumberIsNaN(expr)) + when (expr.callee.name) { + "isInteger" -> { + tryDispatchNumericBuiltin(expr)?.let { return it } + return from(handleNumberIsInteger(expr)) + } + + "isNaN" -> return from(handleNumberIsNaN(expr)) } } @@ -77,16 +83,45 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( } } - // Handle `Math` method calls + // Handle `Math` method calls. if (expr.instance.name == "Math") { - if (expr.callee.name == "floor") { - return from(handleMathFloor(expr)) + when (expr.callee.name) { + "abs", "ceil", "max", "min", "round" -> { + tryDispatchNumericBuiltin(expr)?.let { return it } + return from(handleMathNumeric(expr)) + } + + "floor" -> return from(handleMathFloor(expr)) } } return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.tryDispatchNumericBuiltin( + expr: EtsInstanceCallExpr, +): TsExprApproximationResult? { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return null + } + val resolvedArguments = buildList { + for (argument in expr.args) { + add(resolve(argument) ?: return TsExprApproximationResult.ResolveFailure) + } + } + + dispatcher.dispatch( + scope = scope, + call = expr, + callSite = scope.calcOnState { lastStmt }, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + resolvedArguments = resolvedArguments, + ) + + return TsExprApproximationResult.ResolveFailure +} + internal fun TsExprResolver.tryApproximateInstanceCall( stmt: TsVirtualMethodCallStmt, ): TsExprApproximationResult = with(ctx) { @@ -249,6 +284,102 @@ private fun TsExprResolver.handleNumberIsNaN(expr: EtsInstanceCallExpr): UBoolEx } } +private fun TsExprResolver.handleNumberIsInteger(expr: EtsInstanceCallExpr): UBoolExpr? = with(ctx) { + check(expr.args.size == 1) { + "Number.isInteger() should have exactly one argument, but got ${expr.args.size}" + } + val arg = resolve(expr.args.single()) ?: return null + + fun isInteger(value: UExpr): UBoolExpr { + val truncated = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = value, + ) + return mkAnd( + mkFpIsNaNExpr(value).not(), + mkFpIsInfiniteExpr(value).not(), + mkFpEqualExpr(value, truncated), + ) + } + + if (arg.isFakeObject()) { + val fakeType = arg.getFakeType(scope) + val value = arg.extractFp(scope) + return mkAnd(fakeType.fpTypeExpr, isInteger(value)) + } + + if (arg.sort == fp64Sort) { + isInteger(arg.asExpr(fp64Sort)) + } else { + falseExpr + } +} + +private fun TsExprResolver.handleMathNumeric(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { + val args = expr.args.map { argument -> + val value = resolve(argument) ?: return null + if (value.sort != fp64Sort) { + logger.warn { "Unsupported argument sort for Math.${expr.callee.name}(): ${value.sort}" } + return null + } + + value.asExpr(fp64Sort) + } + + when (expr.callee.name) { + "abs" -> { + check(args.size == 1) { "Math.abs() should have exactly one argument, but got ${args.size}" } + mkFpAbsExpr(args.single()) + } + + "ceil" -> { + check(args.size == 1) { "Math.ceil() should have exactly one argument, but got ${args.size}" } + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), + value = args.single(), + ) + } + + "min" -> args.fold(mkFp(Double.POSITIVE_INFINITY, fp64Sort).asExpr(fp64Sort)) { left, right -> + mkFpMinExpr(left, right) + } + + "max" -> args.fold(mkFp(Double.NEGATIVE_INFINITY, fp64Sort).asExpr(fp64Sort)) { left, right -> + mkFpMaxExpr(left, right) + } + + "round" -> { + check(args.size == 1) { "Math.round() should have exactly one argument, but got ${args.size}" } + val value = args.single() + val roundingMode = fpRoundingModeSortDefaultValue() + val floor = mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative), + value = value, + ) + val fraction = mkFpSubExpr(roundingMode, value, floor) + val rounded = mkIte( + mkFpLessExpr(fraction, mkFp(0.5, fp64Sort)), + floor, + mkFpAddExpr(roundingMode, floor, mkFp(1.0, fp64Sort)), + ) + val signedRounded = mkIte( + mkAnd(mkFpIsNegativeExpr(value), mkFpIsZeroExpr(rounded)), + mkFp(-0.0, fp64Sort), + rounded, + ) + val preserveInput = mkOr( + mkFpIsNaNExpr(value), + mkFpIsInfiniteExpr(value), + mkFpIsZeroExpr(value), + ) + + mkIte(preserveInput, value, signedRounded) + } + + else -> error("Unsupported Math numeric builtin: ${expr.callee.name}") + } +} + private fun TsExprResolver.handleBooleanConstructor(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { check(expr.args.size == 1) { "Boolean constructor should have exactly one argument, but got ${expr.args.size}" diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index e0083ed8e..d993842ec 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -12,6 +12,7 @@ import org.usvm.UMachineOptions import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel +import org.usvm.machine.call.intrinsic.TsNumericIntrinsicModelFamily import org.usvm.machine.state.TsState import kotlin.test.Test import kotlin.test.assertEquals @@ -150,11 +151,22 @@ class TsUnknownCallModelCatalogTest { fun `built in models are discovered once and an explicit empty selection disables all`() { val catalog = TsBuiltInUnknownCallModels.catalog() - assertEquals(listOf("ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID), catalog.modelIds) + val expectedModelIds = listOf( + "ts.array.pop", + TsArrayShiftIntrinsicModel.MODEL_ID, + TsNumericIntrinsicModelFamily.MATH_ABS_ID, + TsNumericIntrinsicModelFamily.MATH_CEIL_ID, + TsNumericIntrinsicModelFamily.MATH_MAX_ID, + TsNumericIntrinsicModelFamily.MATH_MIN_ID, + TsNumericIntrinsicModelFamily.MATH_ROUND_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + ) + + assertEquals(expectedModelIds, catalog.modelIds) assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) assertFailsWith { (catalog.modelIds as MutableList).clear() } assertEquals( - listOf("ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID), + expectedModelIds, TsBuiltInUnknownCallModels.catalog().modelIds, ) assertTrue(TsBuiltInUnknownCallModels.catalog(TsUnknownCallModelSelection.Only(emptySet())).modelIds.isEmpty()) From 28becf25f9d1cfd493bde0db1d88378938b8c520 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:35:20 +0300 Subject: [PATCH 03/33] Test primary numeric intrinsic semantics --- .../intrinsic/TsNumericIntrinsicModels.kt | 96 +++++--- .../usvm/machine/expr/CallApproximations.kt | 104 +-------- .../call/TsNumericIntrinsicModelsTest.kt | 210 ++++++++++++++++++ .../models/NumericIntrinsicModels.ts | 120 ++++++++++ 4 files changed, 394 insertions(+), 136 deletions(-) create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt create mode 100644 usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt index 091dadfca..3296d166c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt @@ -23,39 +23,61 @@ internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily const val NUMBER_IS_INTEGER_ID: String = "ts.number.isInteger" override val models: List = listOf( - NumericIntrinsicModel(MATH_ABS_ID, "abs") { state, call -> - unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } - }, - NumericIntrinsicModel(MATH_CEIL_ID, "ceil") { state, call -> - unaryMathCall(state, call) { value -> - with(state.ctx) { - mkFpRoundToIntegralExpr( - roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), - value = value, - ) + NumericIntrinsicModel( + id = MATH_ABS_ID, + methodName = "abs", + implementation = { state, call -> + unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } + }, + ), + NumericIntrinsicModel( + id = MATH_CEIL_ID, + methodName = "ceil", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), + value = value, + ) + } } - } - }, - NumericIntrinsicModel(MATH_MAX_ID, "max") { state, call -> - variadicMathCall( - state = state, - call = call, - identity = Double.NEGATIVE_INFINITY, - combine = state::mathMax, - ) - }, - NumericIntrinsicModel(MATH_MIN_ID, "min") { state, call -> - variadicMathCall( - state = state, - call = call, - identity = Double.POSITIVE_INFINITY, - combine = state::mathMin, - ) - }, - NumericIntrinsicModel(MATH_ROUND_ID, "round") { state, call -> - unaryMathCall(state, call, state::mathRound) - }, - NumericIntrinsicModel(NUMBER_IS_INTEGER_ID, "isInteger", ::numberIsInteger), + }, + ), + NumericIntrinsicModel( + id = MATH_MAX_ID, + methodName = "max", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.NEGATIVE_INFINITY, + combine = state::mathMax, + ) + }, + ), + NumericIntrinsicModel( + id = MATH_MIN_ID, + methodName = "min", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.POSITIVE_INFINITY, + combine = state::mathMin, + ) + }, + ), + NumericIntrinsicModel( + id = MATH_ROUND_ID, + methodName = "round", + implementation = { state, call -> unaryMathCall(state, call, state::mathRound) }, + ), + NumericIntrinsicModel( + id = NUMBER_IS_INTEGER_ID, + methodName = "isInteger", + implementation = ::numberIsInteger, + ), ) } @@ -71,10 +93,11 @@ private class NumericIntrinsicModel( } private fun numberIsInteger(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { - if (!call.hasGlobalOwner("Number") || call.arguments.size != 1) { + if (!call.hasGlobalOwner("Number")) { return null } - val value = call.arguments.single().resolved ?: return null + val value = call.arguments.firstOrNull()?.resolved + ?: return state.normalExecution(state.ctx.falseExpr) val result = with(state.ctx) { if (value.isFakeObject()) { val type = value.getFakeType(state.memory) @@ -99,10 +122,11 @@ private fun unaryMathCall( call: TsUnknownCall, operation: (UExpr) -> UExpr, ): TsUnknownCallModelExecution? { - if (!call.hasGlobalOwner("Math") || call.arguments.size != 1) { + if (!call.hasGlobalOwner("Math")) { return null } - val argument = call.arguments.single().resolved ?: return null + val argument = call.arguments.firstOrNull()?.resolved + ?: return state.normalExecution(state.ctx.mkFp(Double.NaN, state.ctx.fp64Sort)) if (argument.sort != state.ctx.fp64Sort) { return null } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 969a90c25..654aaa630 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -58,8 +58,8 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( if (expr.instance.name == "Number") { when (expr.callee.name) { "isInteger" -> { - tryDispatchNumericBuiltin(expr)?.let { return it } - return from(handleNumberIsInteger(expr)) + return tryDispatchNumericBuiltin(expr) + ?: TsExprApproximationResult.NoApproximation } "isNaN" -> return from(handleNumberIsNaN(expr)) @@ -87,8 +87,8 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( if (expr.instance.name == "Math") { when (expr.callee.name) { "abs", "ceil", "max", "min", "round" -> { - tryDispatchNumericBuiltin(expr)?.let { return it } - return from(handleMathNumeric(expr)) + return tryDispatchNumericBuiltin(expr) + ?: TsExprApproximationResult.NoApproximation } "floor" -> return from(handleMathFloor(expr)) @@ -284,102 +284,6 @@ private fun TsExprResolver.handleNumberIsNaN(expr: EtsInstanceCallExpr): UBoolEx } } -private fun TsExprResolver.handleNumberIsInteger(expr: EtsInstanceCallExpr): UBoolExpr? = with(ctx) { - check(expr.args.size == 1) { - "Number.isInteger() should have exactly one argument, but got ${expr.args.size}" - } - val arg = resolve(expr.args.single()) ?: return null - - fun isInteger(value: UExpr): UBoolExpr { - val truncated = mkFpRoundToIntegralExpr( - roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), - value = value, - ) - return mkAnd( - mkFpIsNaNExpr(value).not(), - mkFpIsInfiniteExpr(value).not(), - mkFpEqualExpr(value, truncated), - ) - } - - if (arg.isFakeObject()) { - val fakeType = arg.getFakeType(scope) - val value = arg.extractFp(scope) - return mkAnd(fakeType.fpTypeExpr, isInteger(value)) - } - - if (arg.sort == fp64Sort) { - isInteger(arg.asExpr(fp64Sort)) - } else { - falseExpr - } -} - -private fun TsExprResolver.handleMathNumeric(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { - val args = expr.args.map { argument -> - val value = resolve(argument) ?: return null - if (value.sort != fp64Sort) { - logger.warn { "Unsupported argument sort for Math.${expr.callee.name}(): ${value.sort}" } - return null - } - - value.asExpr(fp64Sort) - } - - when (expr.callee.name) { - "abs" -> { - check(args.size == 1) { "Math.abs() should have exactly one argument, but got ${args.size}" } - mkFpAbsExpr(args.single()) - } - - "ceil" -> { - check(args.size == 1) { "Math.ceil() should have exactly one argument, but got ${args.size}" } - mkFpRoundToIntegralExpr( - roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), - value = args.single(), - ) - } - - "min" -> args.fold(mkFp(Double.POSITIVE_INFINITY, fp64Sort).asExpr(fp64Sort)) { left, right -> - mkFpMinExpr(left, right) - } - - "max" -> args.fold(mkFp(Double.NEGATIVE_INFINITY, fp64Sort).asExpr(fp64Sort)) { left, right -> - mkFpMaxExpr(left, right) - } - - "round" -> { - check(args.size == 1) { "Math.round() should have exactly one argument, but got ${args.size}" } - val value = args.single() - val roundingMode = fpRoundingModeSortDefaultValue() - val floor = mkFpRoundToIntegralExpr( - roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardNegative), - value = value, - ) - val fraction = mkFpSubExpr(roundingMode, value, floor) - val rounded = mkIte( - mkFpLessExpr(fraction, mkFp(0.5, fp64Sort)), - floor, - mkFpAddExpr(roundingMode, floor, mkFp(1.0, fp64Sort)), - ) - val signedRounded = mkIte( - mkAnd(mkFpIsNegativeExpr(value), mkFpIsZeroExpr(rounded)), - mkFp(-0.0, fp64Sort), - rounded, - ) - val preserveInput = mkOr( - mkFpIsNaNExpr(value), - mkFpIsInfiniteExpr(value), - mkFpIsZeroExpr(value), - ) - - mkIte(preserveInput, value, signedRounded) - } - - else -> error("Unsupported Math numeric builtin: ${expr.callee.name}") - } -} - private fun TsExprResolver.handleBooleanConstructor(expr: EtsInstanceCallExpr): UExpr<*>? = with(ctx) { check(expr.args.size == 1) { "Boolean constructor should have exactly one argument, but got ${expr.args.size}" diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt new file mode 100644 index 000000000..2b1f4e266 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt @@ -0,0 +1,210 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.intrinsic.TsNumericIntrinsicModelFamily +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsNumericIntrinsicModelsTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/NumericIntrinsicModels.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(sourceFile)) + + @Test + fun `primary Math models preserve binary64 special values and signed zero`() { + val expected = linkedMapOf( + "absNegativeZero" to numberToken(0.0), + "absNaN" to NAN, + "absNegativeInfinity" to numberToken(Double.POSITIVE_INFINITY), + "minSignedZero" to numberToken(-0.0), + "minNaN" to NAN, + "minNoArguments" to numberToken(Double.POSITIVE_INFINITY), + "maxSignedZero" to numberToken(0.0), + "maxInfinity" to numberToken(Double.POSITIVE_INFINITY), + "maxNoArguments" to numberToken(Double.NEGATIVE_INFINITY), + "roundNegativeHalf" to numberToken(-0.0), + "roundPositiveHalf" to numberToken(1.0), + "roundNegativeOneHalf" to numberToken(-1.0), + "roundNaN" to NAN, + "ceilNegativeFraction" to numberToken(-0.0), + "ceilInfinity" to numberToken(Double.POSITIVE_INFINITY), + "absNoArguments" to NAN, + "absExtraArgument" to numberToken(2.0), + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedToken) -> + val actual = assertIs(result.values.getValue(methodName).single()).number + + assertEquals(expectedToken, numberToken(actual), methodName) + } + assertEquals(expected.size, result.events.size) + assertTrue(result.events.all { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `Number isInteger handles finite boundaries and non numbers`() { + val expected = linkedMapOf( + "integerPositiveZero" to true, + "integerNegativeZero" to true, + "integerFraction" to false, + "integerNaN" to false, + "integerInfinity" to false, + "integerLargeBinary64" to true, + "integerBoolean" to false, + "integerNoArguments" to false, + "integerExtraArgument" to true, + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedValue) -> + val actual = assertIs(result.values.getValue(methodName).single()).value + + assertEquals(expectedValue, actual, methodName) + } + assertEquals( + List(expected.size) { TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID }, + result.modelIds, + ) + } + + @Test + fun `symbolic numeric calls use intrinsic models`() { + val methodNames = listOf("symbolicAbs", "symbolicInteger") + + val result = analyze(methodNames) + + val absValues = result.values.getValue("symbolicAbs") + .map { value -> assertIs(value).number } + val integerValues = result.values.getValue("symbolicInteger") + .map { value -> assertIs(value).number } + + assertEquals(setOf(1.0), absValues.toSet()) + assertEquals(setOf(0.0, 1.0), integerValues.toSet()) + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.MATH_ABS_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + ), + result.modelIds.toSet(), + ) + assertTrue(result.events.all { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `unsupported Math domain uses residual fallback`() { + val methodNames = listOf("unsupportedAbsDomain") + + val result = analyze(methodNames) + + assertTrue(result.values.values.all { values -> values.isEmpty() }) + assertTrue(result.modelIds.isEmpty()) + assertEquals( + List(methodNames.size) { TsUnknownCallOutcome.PATH_STOPPED }, + result.events.map { event -> event.outcome }, + ) + } + + @Test + fun `disabled numeric model uses configured fallback`() { + val result = analyze( + methodNames = listOf("absNegativeZero"), + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, + ), + ) + + assertTrue(result.values.getValue("absNegativeZero").isEmpty()) + assertTrue(result.modelIds.isEmpty()) + assertEquals(listOf(TsUnknownCallOutcome.PATH_STOPPED), result.events.map { event -> event.outcome }) + } + + private fun analyze( + methodNames: List, + tsOptions: TsOptions = TsOptions(), + ): AnalysisResult { + val methods = methodNames.associateWith(::method) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + observer = observer, + ).use { machine -> + val states = machine.analyze(methods.values.toList()) + val values = methods.mapValues { (_, method) -> + states.filter { state -> state.entrypoint === method } + .map { state -> TsTestResolver().resolve(method, state).returnValue } + } + + AnalysisResult( + values = values, + events = observer.events.toList(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == DEFAULT_ARK_CLASS_NAME && clazz.declaringFile === sourceFile } + .methods + .single { method -> method.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: Map>, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + const val NAN: String = "nan" + + fun numberToken(value: Double): String = + if (value.isNaN()) NAN else value.toRawBits().toULong().toString(radix = 16).padStart(16, '0') + + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts new file mode 100644 index 000000000..707a18713 --- /dev/null +++ b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts @@ -0,0 +1,120 @@ +export function absNegativeZero(): number { + return Math.abs(-0); +} + +export function absNaN(): number { + return Math.abs(0 / 0); +} + +export function absNegativeInfinity(): number { + return Math.abs(-1 / 0); +} + +export function minSignedZero(): number { + return Math.min(0, -0); +} + +export function minNaN(): number { + return Math.min(1, 0 / 0, 2); +} + +export function minNoArguments(): number { + return Math.min(); +} + +export function maxSignedZero(): number { + return Math.max(-0, 0); +} + +export function maxInfinity(): number { + return Math.max(-1 / 0, 1 / 0, 42); +} + +export function maxNoArguments(): number { + return Math.max(); +} + +export function roundNegativeHalf(): number { + return Math.round(-0.5); +} + +export function roundPositiveHalf(): number { + return Math.round(0.5); +} + +export function roundNegativeOneHalf(): number { + return Math.round(-1.5); +} + +export function roundNaN(): number { + return Math.round(0 / 0); +} + +export function ceilNegativeFraction(): number { + return Math.ceil(-0.25); +} + +export function ceilInfinity(): number { + return Math.ceil(1 / 0); +} + +export function absNoArguments(): number { + // @ts-expect-error Arity test deliberately omits the first argument. + return Math.abs(); +} + +export function absExtraArgument(): number { + // @ts-expect-error Arity test deliberately supplies an extra argument. + return Math.abs(-2, true); +} + +export function integerPositiveZero(): boolean { + return Number.isInteger(0); +} + +export function integerNegativeZero(): boolean { + return Number.isInteger(-0); +} + +export function integerFraction(): boolean { + return Number.isInteger(1.5); +} + +export function integerNaN(): boolean { + return Number.isInteger(0 / 0); +} + +export function integerInfinity(): boolean { + return Number.isInteger(1 / 0); +} + +export function integerLargeBinary64(): boolean { + return Number.isInteger(9007199254740992); +} + +export function integerBoolean(): boolean { + return Number.isInteger(true); +} + +export function integerNoArguments(): boolean { + // @ts-expect-error Arity test deliberately omits the first argument. + return Number.isInteger(); +} + +export function integerExtraArgument(): boolean { + // @ts-expect-error Arity test deliberately supplies an extra argument. + return Number.isInteger(2, true); +} + +export function symbolicAbs(value: number): number { + return Math.abs(value) < 0 ? 0 : 1; +} + +export function symbolicInteger(value: number): number { + return Number.isInteger(value) ? 1 : 0; +} + +export function unsupportedAbsDomain(): number { + // @ts-expect-error Domain fallback deliberately supplies a non-number. + return Math.abs(true); +} From f403bbe7d6627a69b369dbf1d61aecbae3d61127 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:37:41 +0300 Subject: [PATCH 04/33] Allow calls runner to use bundled frontend --- usvm-ts-calls/build.gradle.kts | 7 +++- .../org/usvm/ts/calls/CallsExperiment.kt | 11 +++++- .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 37 ++++++++++++++----- .../calls/CurrentTsCallsSymbolicEngineTest.kt | 33 +++++++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt diff --git a/usvm-ts-calls/build.gradle.kts b/usvm-ts-calls/build.gradle.kts index bcc0ba431..3d7f8f67d 100644 --- a/usvm-ts-calls/build.gradle.kts +++ b/usvm-ts-calls/build.gradle.kts @@ -30,6 +30,7 @@ val toolStatus = providers.exec { val generateBuildMetadata = tasks.register("generateBuildMetadata") { inputs.property("toolRevision", toolRevision) inputs.property("toolStatus", toolStatus) + inputs.property("jacodbVersion", Versions.jacodb) outputs.dir(generatedBuildMetadataDirectory) doLast { @@ -39,7 +40,11 @@ val generateBuildMetadata = tasks.register("generateBuildMetadata") { .file("org/usvm/ts/calls/build.properties") .asFile metadataFile.parentFile.mkdirs() - metadataFile.writeText("tool.revision=$buildIdentity\n", Charsets.UTF_8) + metadataFile.writeText( + "tool.revision=$buildIdentity\n" + + "native.frontend.revision=bundled:${Versions.jacodb}\n", + Charsets.UTF_8, + ) } } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 3746582ff..606dab188 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -244,16 +244,25 @@ internal object CallsExperimentJson { } internal object CallsBuildIdentity { - val toolRevision: String by lazy { + private val properties: Properties by lazy { val properties = Properties() val resource = checkNotNull(javaClass.getResourceAsStream("/org/usvm/ts/calls/build.properties")) { "Missing calls build identity" } resource.use(properties::load) + properties + } + + val toolRevision: String by lazy { checkNotNull(properties.getProperty("tool.revision")).takeIf(String::isNotBlank) ?: error("Missing tool revision in calls build identity") } + + val nativeFrontendRevision: String by lazy { + checkNotNull(properties.getProperty("native.frontend.revision")).takeIf(String::isNotBlank) + ?: error("Missing native frontend revision in calls build identity") + } } internal class CallsExperimentRunner( diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index b7767ed42..69b6cc469 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -30,9 +30,12 @@ import org.usvm.util.mkRegisterStackLValue import java.nio.file.Path import kotlin.time.TimeSource -internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { +internal class CurrentTsCallsSymbolicEngine( + private val environment: (String) -> String? = System::getenv, + private val bundledNativeFrontendRevision: String = CallsBuildIdentity.nativeFrontendRevision, +) : CallsSymbolicEngine { private val verifiedProjects = mutableMapOf() - private var verifiedNativeFrontend: Pair? = null + private var verifiedNativeFrontendIdentity: String? = null override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { val startedAt = TimeSource.Monotonic.markNow() @@ -238,22 +241,34 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { diagnostic = diagnostic, ) - private fun verifyNativeFrontendOnce(expectedRevision: String) { - require(System.getenv("ETS_FRONTEND_SCRIPT") == null) { + internal fun verifyNativeFrontendOnce(expectedRevision: String) { + val configuredScript = environment("ETS_FRONTEND_SCRIPT") + require(configuredScript == null) { "ETS_FRONTEND_SCRIPT must be unset so the frozen native frontend runtime is used" } - val configuredFrontend = requireNotNull(System.getenv("ETS_FRONTEND_DIR")) { + if (expectedRevision.startsWith(BUNDLED_FRONTEND_PREFIX)) { + require(environment("ETS_FRONTEND_DIR") == null) { + "ETS_FRONTEND_DIR must be unset when the bundled native frontend is selected" + } + require(expectedRevision == bundledNativeFrontendRevision) { + "Bundled native frontend revision $expectedRevision does not match running build " + + bundledNativeFrontendRevision + } + verifiedNativeFrontendIdentity = expectedRevision + return + } + + val configuredFrontend = requireNotNull(environment("ETS_FRONTEND_DIR")) { "ETS_FRONTEND_DIR is required to verify the frozen native frontend revision" } val frontendDirectory = Path.of(configuredFrontend).toRealPath() - val cached = verifiedNativeFrontend - val expectedIdentity = expectedRevision - if (cached == Pair(frontendDirectory, expectedIdentity)) { + val expectedIdentity = "$frontendDirectory@$expectedRevision" + if (verifiedNativeFrontendIdentity == expectedIdentity) { return } verifyCallsGitCheckout(frontendDirectory, expectedRevision) - verifiedNativeFrontend = frontendDirectory to expectedIdentity + verifiedNativeFrontendIdentity = expectedIdentity } private fun verifyGitCheckoutOnce( @@ -285,6 +300,10 @@ internal class CurrentTsCallsSymbolicEngine : CallsSymbolicEngine { val states: List, val stopReason: TsAnalysisStopReason, ) + + private companion object { + const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" + } } internal data class SourceStatementEntry( diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt new file mode 100644 index 000000000..b8923279e --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -0,0 +1,33 @@ +package org.usvm.ts.calls + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class CurrentTsCallsSymbolicEngineTest { + @Test + fun `bundled frontend accepts only the revision baked into the running build`() { + val engine = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:published-jacodb", + ) + + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + + assertFailsWith { + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:different-jacodb") + } + } + + @Test + fun `bundled frontend rejects native frontend environment overrides`() { + val environment = mapOf("ETS_FRONTEND_DIR" to "/unused/frontend") + val engine = CurrentTsCallsSymbolicEngine( + environment = environment::get, + bundledNativeFrontendRevision = "bundled:published-jacodb", + ) + + assertFailsWith { + engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") + } + } +} From 51d405d945931cec6d9157d284d3a42c6935b86d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:39:54 +0300 Subject: [PATCH 05/33] Model Array and String search methods in TypeScript --- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 105 ++++++ .../call/intrinsic/TsArrayPopEtsIrModel.kt | 66 ---- .../intrinsic/TsStringEtsIrModelFamily.kt | 324 ++++++++++++++++++ .../usvm/machine/expr/CallApproximations.kt | 66 +++- .../usvm/machine/call/models/ArrayModels.ts | 82 +++++ .../usvm/machine/call/models/StringModels.ts | 100 ++++++ .../machine/call/TsSequenceEtsIrModelTest.kt | 184 ++++++++++ .../call/TsUnknownCallModelCatalogTest.kt | 8 + .../test/resources/models/SequenceEtsIr.ts | 66 ++++ 9 files changed, 933 insertions(+), 68 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt delete mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt create mode 100644 usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/resources/models/SequenceEtsIr.ts diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt new file mode 100644 index 000000000..33545151c --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -0,0 +1,105 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.util.arrayStorageType + +/** Built-in Array algorithms implemented by ordinary TypeScript bodies. */ +internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val CLASS_NAME = "ArrayModels" + private const val RESOURCE_NAME = "/org/usvm/machine/call/models/ArrayModels.ts" + + private val baseArtifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE_NAME, + sourceFileName = "ArrayModels.ts", + entryPointClassName = CLASS_NAME, + entryPointMethodName = "pop", + ) + } + + private val arrayDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiver = inputs.firstOrNull() + val staticType = call.receiver?.source?.type + if (staticType == null || receiver?.sort != addressSort) { + falseExpr + } else { + val array = receiver.asExpr(addressSort) + val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType + // Array storage has no presence bit yet, so indexOf(undefined) cannot distinguish a hole. + val searchesForUndefined = call.callee.name == "indexOf" && + inputs.getOrNull(1) == mkUndefinedValue() + if (array.hasFakeValueBranch() || receiverType?.dimensions != 1 || searchesForUndefined) { + falseExpr + } else { + state.memory.types.evalIsSubtype(array, receiverType) + } + } + } + } + + private val optionalFromIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> + when { + call.arguments.size == 1 -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + + override val models: List by lazy { + listOf( + sourceModel( + id = "ts.array.pop", + methodName = "pop", + ), + sourceModel( + id = "ts.array.indexOf", + methodName = "indexOf", + inputAdapter = optionalFromIndexAdapter, + ), + sourceModel( + id = "ts.array.includes", + methodName = "includes", + inputAdapter = optionalFromIndexAdapter, + ), + ) + } + + private fun sourceModel( + id: String, + methodName: String, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, + ): TsUnknownCallModel = TsEtsIrUnknownCallModel( + id = id, + target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = "Array".takeUnless { methodName == "pop" }, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ), + artifact = artifact(methodName), + domainGuard = arrayDomain, + inputAdapter = inputAdapter, + ) + + private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { + val artifact = baseArtifact + val entryPoint = artifact.file.allClasses + .single { it.name == CLASS_NAME } + .methods + .single { it.name == methodName } + + return artifact.copy(entryPoint = entryPoint) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt deleted file mode 100644 index e6e0ad88c..000000000 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayPopEtsIrModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.usvm.machine.call.intrinsic - -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsArrayType -import org.jacodb.ets.model.EtsFile -import org.usvm.machine.call.TsEtsIrUnknownCallModel -import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard -import org.usvm.machine.call.TsMachineLocalUnknownCallModel -import org.usvm.machine.call.TsUnknownCall -import org.usvm.machine.call.TsUnknownCallFailureReason -import org.usvm.machine.call.TsUnknownCallModel -import org.usvm.machine.call.TsUnknownCallTarget -import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact -import org.usvm.machine.state.TsState -import org.usvm.util.arrayStorageType -import java.util.IdentityHashMap - -/** Built-in `Array.pop` implemented by an ordinary TypeScript body. */ -internal object TsArrayPopEtsIrModel : TsBuiltInUnknownCallModel, TsMachineLocalUnknownCallModel { - override val id: String = "ts.array.pop" - override val target = TsUnknownCallTarget( - methodName = "pop", - failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, - ) - - private val model by lazy { - val artifact = loadBundledEtsIrUnknownCallModelArtifact( - resourceName = "/org/usvm/machine/call/models/ArrayModels.ts", - sourceFileName = "ArrayModels.ts", - entryPointClassName = "ArrayModels", - entryPointMethodName = "pop", - ) - val domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> - with(state.ctx) { - val receiver = inputs.singleOrNull() - val staticType = call.receiver?.source?.type - if (staticType == null || receiver?.sort != addressSort) { - falseExpr - } else { - val array = receiver.asExpr(addressSort) - val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType - if (array.hasFakeValueBranch() || receiverType?.dimensions != 1) { - falseExpr - } else { - state.memory.types.evalIsSubtype(array, receiverType) - } - } - } - } - - TsEtsIrUnknownCallModel( - id = id, - target = target, - artifact = artifact, - domainGuard = domainGuard, - ) - } - - override val additionalSceneFiles get() = model.additionalSceneFiles - - override fun apply(state: TsState, call: TsUnknownCall) = model.apply(state, call) - - override fun materializeForMachine( - materializedFiles: IdentityHashMap, - ): TsUnknownCallModel = model.materializeForMachine(materializedFiles) -} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt new file mode 100644 index 000000000..188021ed0 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -0,0 +1,324 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.expr.KFp64Value +import io.ksmt.utils.asExpr +import io.ksmt.utils.cast +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.api.evalTypeEquals +import org.usvm.api.initializeArray +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.state.TsState +import org.usvm.sizeSort +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkArrayLengthLValue +import org.usvm.util.mkFieldLValue + +/** Built-in String algorithms implemented by ordinary TypeScript bodies. */ +internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val CLASS_NAME = "StringModels" + private const val PRIMITIVES_CLASS_NAME = "StringModelPrimitives" + private const val RESOURCE_NAME = "/org/usvm/machine/call/models/StringModels.ts" + + private val characterArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + + private val baseArtifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE_NAME, + sourceFileName = "StringModels.ts", + entryPointClassName = CLASS_NAME, + entryPointMethodName = "charAt", + ) + } + + private val optionalIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> + when { + call.arguments.isEmpty() -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 1 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + + private val optionalPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> + when { + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> + inputs + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last().sort == state.ctx.fp64Sort -> inputs + + else -> null + } + } + + private val receiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val receiver = inputs.firstOrNull() + // Symbolic String parameters do not initialize the backing character array yet. + if (receiver !is UConcreteHeapRef || getStringConstantValue(receiver) == null) { + falseExpr + } else { + state.memory.types.evalTypeEquals(receiver, EtsStringType) + } + } + } + + private val receiverAndSearchDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val receiver = inputs.getOrNull(0) + val searchString = inputs.getOrNull(1) + if ( + receiver !is UConcreteHeapRef || searchString !is UConcreteHeapRef || + getStringConstantValue(receiver) == null || getStringConstantValue(searchString) == null + ) { + falseExpr + } else { + mkAnd( + state.memory.types.evalTypeEquals(receiver, EtsStringType), + state.memory.types.evalTypeEquals(searchString, EtsStringType), + ) + } + } + } + + override val models: List by lazy { + listOf( + sourceModel( + id = "ts.string.charAt", + methodName = "charAt", + inputAdapter = optionalIndexAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.indexOf", + methodName = "indexOf", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.includes", + methodName = "includes", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + primitiveModel( + methodName = "length", + arity = 1, + implementation = ::stringLength, + ), + primitiveModel( + methodName = "codeUnitAt", + arity = 2, + implementation = ::stringCodeUnitAt, + ), + primitiveModel( + methodName = "fromCodeUnit", + arity = 1, + implementation = ::stringFromCodeUnit, + ), + ) + } + + private fun sourceModel( + id: String, + methodName: String, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter, + domainGuard: TsEtsIrUnknownCallModelDomainGuard, + ): TsUnknownCallModel = TsEtsIrUnknownCallModel( + id = id, + target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = "String", + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + ), + artifact = artifact(methodName), + domainGuard = domainGuard, + inputAdapter = inputAdapter, + ) + + private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { + val artifact = baseArtifact + val entryPoint = artifact.file.allClasses + .single { it.name == CLASS_NAME } + .methods + .single { it.name == methodName } + + return artifact.copy(entryPoint = entryPoint) + } + + private fun primitiveModel( + methodName: String, + arity: Int, + implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ): TsUnknownCallModel = StringPrimitiveModel( + methodName = methodName, + arity = arity, + implementation = implementation, + ) + + private fun stringLength( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.singleOrNull()?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + val length = state.memory.read(mkArrayLengthLValue(characters, characterArrayType)) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val result = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.cast(), + signed = true, + ) + + singleSuccessor( + state = state, + guard = receiverIsString, + result = result, + ) + } + + private fun stringCodeUnitAt( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0)?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + val fpIndex = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val index = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpIndex, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + val codeUnit = state.memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = index, + type = characterArrayType, + ) + ) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val result = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = codeUnit.cast(), + signed = false, + ) + + singleSuccessor( + state = state, + guard = receiverIsString, + result = result, + ) + } + + private fun stringFromCodeUnit( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val code = inputs.singleOrNull()?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + + TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { + if (code is KFp64Value) { + return@Normal mkInitializedStringConstant(code.value.toInt().toChar().toString()) + } + + val codeUnit = ctx.mkFpToBvExpr( + roundingMode = ctx.fpRoundingModeSortDefaultValue(), + value = code, + bvSize = ctx.bv16Sort.sizeBits.toInt(), + isSigned = false, + ).asExpr(ctx.bv16Sort) + val result = memory.allocConcrete(EtsStringType) + val characters = memory.allocConcrete(characterArrayType.elementType) + memory.initializeArray( + arrayHeapRef = characters, + type = ctx.arrayDescriptorOf(characterArrayType), + sort = ctx.bv16Sort, + sizeSort = ctx.sizeSort, + contents = sequenceOf(codeUnit), + ) + memory.write( + mkFieldLValue(ctx.addressSort, result, "value"), + characters, + guard = ctx.trueExpr, + ) + + result + }, + ) + ), + ) + } + + private fun singleSuccessor( + state: TsState, + guard: org.usvm.UBoolExpr, + result: UExpr<*>, + ): TsUnknownCallModelExecution = TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + ), + residualGuard = guard.takeUnless { it == state.ctx.trueExpr }?.let(state.ctx::mkNot), + ) + + private class StringPrimitiveModel( + methodName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ) : TsUnknownCallModel { + override val id: String = "ts.string.primitive.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (call.receiver != null || call.arguments.size != arity) { + return null + } + + val inputs = call.arguments.map { argument -> argument.resolved ?: return null } + return implementation(state, inputs) + } + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 654aaa630..f16fa9fe7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -7,6 +7,7 @@ import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsUnknownType import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UBoolExpr @@ -193,12 +194,12 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Handle `Array.indexOf() method calls if (expr.callee.name == "indexOf") { - return from(handleArrayIndexOf(expr, instanceType, elementSort, array)) + return handleArrayIndexOfCall(stmt, instanceType, elementSort, array) } // Handle `Array.includes() method calls if (expr.callee.name == "includes") { - return from(handleArrayIncludes(expr)) + return handleArrayIncludesCall(stmt) } // Handle `Array.reverse() method calls @@ -207,9 +208,70 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } } + if (instanceType is EtsStringType && expr.callee.name in setOf("charAt", "indexOf", "includes")) { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return TsExprApproximationResult.NoApproximation + } + + dispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + callee = stmt.call.callee.withEnclosingClassName("String"), + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) + + return TsExprApproximationResult.ResolveFailure + } + return TsExprApproximationResult.NoApproximation } +private fun TsExprResolver.handleArrayIndexOfCall( + stmt: TsVirtualMethodCallStmt, + instanceType: EtsArrayType, + elementSort: USort, + array: UHeapRef, +): TsExprApproximationResult { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return from(handleArrayIndexOf(stmt.call, instanceType, elementSort, array)) + } + + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure +} + +private fun TsExprResolver.handleArrayIncludesCall( + stmt: TsVirtualMethodCallStmt, +): TsExprApproximationResult { + val dispatcher = unknownCallDispatcher + if (dispatcher !is TsUnknownCallModelDispatcher) { + return from(handleArrayIncludes(stmt.call)) + } + + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure +} + +private fun TsExprResolver.dispatchArrayModel(stmt: TsVirtualMethodCallStmt) { + unknownCallDispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + callee = stmt.call.callee.withEnclosingClassName("Array"), + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) +} + +private fun EtsMethodSignature.withEnclosingClassName(name: String): EtsMethodSignature = + copy(enclosingClass = enclosingClass.copy(name = name)) + private fun TsExprResolver.handleArrayPopCall( stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 0e22541d9..eb8fc45ea 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -9,4 +9,86 @@ export class ArrayModels { receiver.length = length - 1; return result; } + + static indexOf(receiver: any[], searchElement: any, fromIndex: number): number { + const length = receiver.length; + if (length === 0) { + return -1; + } + + const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + if (start === Infinity || start >= length) { + return -1; + } + + let index = start >= 0 ? start : length + start; + if (index < 0) { + index = 0; + } + + while (index < length) { + if (receiver[index] === searchElement) { + return index; + } + + index++; + } + + return -1; + } + + static includes(receiver: any[], searchElement: any, fromIndex: number): boolean { + const length = receiver.length; + if (length === 0) { + return false; + } + + const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + if (start === Infinity || start >= length) { + return false; + } + + let index = start >= 0 ? start : length + start; + if (index < 0) { + index = 0; + } + + while (index < length) { + const element = receiver[index]; + if (element === searchElement || (element !== element && searchElement !== searchElement)) { + return true; + } + + index++; + } + + return false; + } + + private static normalizeRelativeIndex(fromIndex: number, length: number): number { + if (fromIndex !== fromIndex) { + return 0; + } + + if (fromIndex === Infinity || fromIndex >= length) { + return length; + } + + if (fromIndex === -Infinity || fromIndex <= -length) { + return -length; + } + + let integer = 0; + if (fromIndex > 0) { + while (integer + 1 <= fromIndex) { + integer++; + } + } else { + while (integer - 1 >= fromIndex) { + integer--; + } + } + + return integer; + } } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts new file mode 100644 index 000000000..836a2428b --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -0,0 +1,100 @@ +declare class StringModelPrimitives { + static length(receiver: string): number; + static codeUnitAt(receiver: string, index: number): number; + static fromCodeUnit(codeUnit: number): string; +} + +export class StringModels { + static charAt(receiver: string, index: number): string { + const length = StringModelPrimitives.length(receiver); + const integerIndex = StringModels.normalizeCharIndex(index, length); + if (integerIndex < 0 || integerIndex >= length) { + return ""; + } + + const codeUnit = StringModelPrimitives.codeUnitAt(receiver, integerIndex); + return StringModelPrimitives.fromCodeUnit(codeUnit); + } + + static indexOf(receiver: string, searchString: string, position: number): number { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const start = StringModels.normalizePosition(position, length); + + if (searchLength === 0) { + return start; + } + + let index = start; + while (index + searchLength <= length) { + let searchIndex = 0; + while ( + searchIndex < searchLength && + StringModelPrimitives.codeUnitAt(receiver, index + searchIndex) === + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + searchIndex++; + } + + if (searchIndex === searchLength) { + return index; + } + + index++; + } + + return -1; + } + + static includes(receiver: string, searchString: string, position: number): boolean { + return StringModels.indexOf(receiver, searchString, position) !== -1; + } + + private static normalizeCharIndex(value: number, length: number): number { + if (value !== value) { + return 0; + } + + if (value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= -length) { + return -length; + } + + let integer = 0; + if (value > 0) { + while (integer + 1 <= value) { + integer++; + } + } else { + while (integer - 1 >= value) { + integer--; + } + } + + return integer; + } + + private static normalizePosition(value: number, length: number): number { + if (value !== value) { + return 0; + } + + if (value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= 0) { + return 0; + } + + let integer = 0; + while (integer + 1 <= value) { + integer++; + } + + return integer; + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt new file mode 100644 index 000000000..8cd97b35f --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -0,0 +1,184 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsSequenceEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/SequenceEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `array indexOf uses strict equality and offsets`() { + val result = analyze(methodName = "arrayIndexOfUsesStrictEqualityAndOffsets") + + assertEquals(-681.0, assertIs(result.values.single()).number) + assertEquals(listOf("ts.array.indexOf"), result.modelIds.distinct()) + } + + @Test + fun `array includes uses SameValueZero`() { + val result = analyze(methodName = "arrayIncludesUsesSameValueZero") + + assertTrue(assertIs(result.values.single()).value) + assertEquals(listOf("ts.array.includes"), result.modelIds.distinct()) + } + + @Test + fun `array offsets normalize fractions and NaN`() { + val result = analyze(methodName = "arrayOffsetsAreNormalized") + + assertEquals(131.0, assertIs(result.values.single()).number) + assertEquals(setOf("ts.array.includes", "ts.array.indexOf"), result.modelIds.toSet()) + } + + @Test + fun `empty arrays do not match`() { + val result = analyze(methodName = "emptyArraysDoNotMatch") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `explicit undefined uses the default array offset`() { + val result = analyze(methodName = "arrayExplicitUndefinedOffset") + + assertEquals(0.0, assertIs(result.values.single()).number) + } + + @Test + fun `array includes finds explicit undefined`() { + val result = analyze(methodName = "explicitUndefinedArrayIncludesUndefined") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `array indexOf rejects undefined search when slot presence is unavailable`() { + val result = analyze(methodName = "explicitUndefinedArrayIndexOfUndefined") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `string charAt handles in-range and out-of-range indexes`() { + val result = analyze(methodName = "stringCharAtHandlesBounds") + + assertEquals("b", assertIs(result.values.single()).value) + assertTrue("ts.string.charAt" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `string indexOf handles offsets and empty search`() { + val result = analyze(methodName = "stringIndexOfHandlesOffsetsAndEmptySearch") + + assertEquals(330.0, assertIs(result.values.single()).number) + assertTrue("ts.string.indexOf" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `string includes handles NaN and infinity positions`() { + val result = analyze(methodName = "stringIncludesHandlesNaNPosition") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.includes" in result.modelIds) + assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + @Test + fun `explicit undefined uses default string positions`() { + val result = analyze(methodName = "stringExplicitUndefinedPositions") + + assertEquals(1.0, assertIs(result.values.single()).number) + } + + @Test + fun `symbolic string position explores exact matches`() { + val result = analyze(methodName = "stringSymbolicPosition") + val numbers = result.values.filterIsInstance().map { it.number }.toSet() + + assertTrue(1.0 in numbers, "Expected first match for positions at or before 1: $numbers") + assertTrue(3.0 in numbers, "Expected second match for positions 2 or 3: $numbers") + assertTrue(-1.0 in numbers, "Expected no match after the last occurrence: $numbers") + assertTrue("ts.string.indexOf" in result.modelIds) + } + + private fun analyze(methodName: String): AnalysisResult { + val method = method(methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + val states = machine.analyze(listOf(method)) + val values = states.map { state -> TsTestResolver().resolve(method, state).returnValue } + + AnalysisResult( + values = values, + events = observer.events.toList(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "SequenceEtsIr" } + .methods + .single { it.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: List, + val events: List, + ) { + val modelIds: List + get() = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index d993842ec..511eb4771 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -152,6 +152,8 @@ class TsUnknownCallModelCatalogTest { val catalog = TsBuiltInUnknownCallModels.catalog() val expectedModelIds = listOf( + "ts.array.includes", + "ts.array.indexOf", "ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID, TsNumericIntrinsicModelFamily.MATH_ABS_ID, @@ -160,6 +162,12 @@ class TsUnknownCallModelCatalogTest { TsNumericIntrinsicModelFamily.MATH_MIN_ID, TsNumericIntrinsicModelFamily.MATH_ROUND_ID, TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + "ts.string.charAt", + "ts.string.includes", + "ts.string.indexOf", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.fromCodeUnit", + "ts.string.primitive.length", ) assertEquals(expectedModelIds, catalog.modelIds) diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts new file mode 100644 index 000000000..ca3e99488 --- /dev/null +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class SequenceEtsIr { + arrayIndexOfUsesStrictEqualityAndOffsets(): number { + const values = [NaN, 2, 3, 2]; + return values.indexOf(NaN) * 1000 + + values.indexOf(2, 2) * 100 + + values.indexOf(3, -Infinity) * 10 + + values.indexOf(2, Infinity); + } + + arrayIncludesUsesSameValueZero(): boolean { + return [NaN].includes(NaN) && ![NaN].includes(0); + } + + arrayOffsetsAreNormalized(): number { + const values = [1, 2, 3, 2]; + return values.indexOf(2, 1.9) * 100 + + values.indexOf(2, -1.9) * 10 + + (values.includes(1, NaN) ? 1 : 0); + } + + emptyArraysDoNotMatch(): boolean { + return ![].includes(1) && [].indexOf(1) === -1; + } + + arrayExplicitUndefinedOffset(): number { + return [1].indexOf(1, undefined); + } + + explicitUndefinedArrayIncludesUndefined(): boolean { + const values = [undefined]; + return values.includes(undefined); + } + + explicitUndefinedArrayIndexOfUndefined(): number { + const values = [undefined]; + return values.indexOf(undefined); + } + + stringCharAtHandlesBounds(): string { + return "abc".charAt(1) + "abc".charAt(-1); + } + + stringIndexOfHandlesOffsetsAndEmptySearch(): number { + return "ababa".indexOf("ba", 2) * 100 + + "abc".indexOf("", Infinity) * 10 + + "abc".indexOf("a", -Infinity); + } + + stringIncludesHandlesNaNPosition(): boolean { + return "abc".includes("a", NaN) && !"abc".includes("a", Infinity); + } + + stringExplicitUndefinedPositions(): number { + return "abc".indexOf("a", undefined) + ("abc".charAt(undefined) === "a" ? 1 : 0); + } + + stringSymbolicPosition(position: number): number { + if (position === 0) return "ababa".indexOf("ba", position); + if (position === 2) return "ababa".indexOf("ba", position); + if (position === 4) return "ababa".indexOf("ba", position); + return -100; + } +} From 6ec06c898d81ebba26789ff32647239365660875 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:44:41 +0300 Subject: [PATCH 06/33] Let EtsIR adapters resolve raw call inputs --- .../kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt index 304ee1633..66dea8409 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt @@ -116,11 +116,9 @@ class TsEtsIrUnknownCallModel( override val additionalSceneFiles: List = listOf(artifact.file) override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { - val resolvedInputs = call.resolvedInputs() ?: return null val inputs = inputAdapter.adapt( state = state, call = call, - resolvedInputs = resolvedInputs, ) ?: return null if (inputs.size != artifact.entryPoint.parameters.size) { return null @@ -170,11 +168,10 @@ fun interface TsEtsIrUnknownCallModelInputAdapter { fun adapt( state: TsState, call: TsUnknownCall, - resolvedInputs: List>, ): List>? companion object { - val IDENTITY = TsEtsIrUnknownCallModelInputAdapter { _, _, resolvedInputs -> resolvedInputs } + val IDENTITY = TsEtsIrUnknownCallModelInputAdapter { _, call -> call.resolvedInputs() } } } From 3f826bc15184b12222fbecdd285ab376724aa448 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:48:09 +0300 Subject: [PATCH 07/33] Test adapters with unresolved namespace receivers --- .../TsEtsIrUnknownCallModelExecutionTest.kt | 23 +++++++++++++++++++ .../models/EtsIrSemanticModelCalls.ts | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt index 67e215516..69e78589b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModelExecutionTest.kt @@ -38,6 +38,16 @@ class TsEtsIrUnknownCallModelExecutionTest { private val modelClass = baseArtifact.file.allClasses.single { it.name == "EtsIrSemanticModels" } private val models = TsUnknownCallModelCatalog( models = listOf( + model( + id = "test.ets-ir.namespace-adapter", + targetName = "abs", + entryPointName = "absolute", + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + } + }, + ), model( id = "test.ets-ir.absolute", targetName = "absolute", @@ -93,6 +103,17 @@ class TsEtsIrUnknownCallModelExecutionTest { ), ) + @Test + fun `custom adapter can ignore an unresolved namespace receiver`() { + val result = analyze(methodName = "namespaceReceiverCanBeIgnored") + + assertTrue( + result.values.filterIsInstance().any { value -> value.number == 2.0 }, + result.values.toString(), + ) + assertEquals(listOf("test.ets-ir.namespace-adapter"), result.modelIds.distinct()) + } + @Test fun `pure EtsIR body maps argument and return value`() { val result = analyze(methodName = "pureArgumentAndReturn") @@ -202,6 +223,7 @@ class TsEtsIrUnknownCallModelExecutionTest { targetName: String, entryPointName: String, domainGuard: TsEtsIrUnknownCallModelDomainGuard = TsEtsIrUnknownCallModelDomainGuard.ALWAYS, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, ): TsUnknownCallModel { val artifact = baseArtifact.copy( entryPoint = modelClass.methods.single { it.name == entryPointName }, @@ -212,6 +234,7 @@ class TsEtsIrUnknownCallModelExecutionTest { target = TsUnknownCallTarget(methodName = targetName), artifact = artifact, domainGuard = domainGuard, + inputAdapter = inputAdapter, ) } diff --git a/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts b/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts index 3c9c667e5..adad1b361 100644 --- a/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts +++ b/usvm-ts/src/test/resources/models/EtsIrSemanticModelCalls.ts @@ -11,6 +11,10 @@ declare class ExternalModels { } export class EtsIrSemanticModelCalls { + namespaceReceiverCanBeIgnored(): number { + return Math.abs(-2); + } + pureArgumentAndReturn(): number { return ExternalModels.absolute(-42); } From c7daaf0da3faae05156b21e9d3fb0fdeaf57ea1a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:48:14 +0300 Subject: [PATCH 08/33] Extend source sequence models --- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 58 ++++++++-- .../intrinsic/TsStringEtsIrModelFamily.kt | 99 +++++++++++++---- .../usvm/machine/expr/CallApproximations.kt | 23 +++- .../usvm/machine/call/models/ArrayModels.ts | 19 ++++ .../usvm/machine/call/models/StringModels.ts | 102 ++++++++++++++++++ .../machine/call/TsSequenceEtsIrModelTest.kt | 63 +++++++++++ .../call/TsUnknownCallModelCatalogTest.kt | 5 + .../test/resources/models/SequenceEtsIr.ts | 44 +++++++- 8 files changed, 377 insertions(+), 36 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index 33545151c..82b57ffc4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -1,11 +1,14 @@ package org.usvm.machine.call.intrinsic +import io.ksmt.expr.KFp64Value import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType +import org.usvm.UExpr import org.usvm.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModel import org.usvm.machine.call.TsUnknownCallTarget @@ -35,10 +38,16 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } else { val array = receiver.asExpr(addressSort) val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType - // Array storage has no presence bit yet, so indexOf(undefined) cannot distinguish a hole. - val searchesForUndefined = call.callee.name == "indexOf" && - inputs.getOrNull(1) == mkUndefinedValue() - if (array.hasFakeValueBranch() || receiverType?.dimensions != 1 || searchesForUndefined) { + val searchElement = inputs.getOrNull(1) + // Array storage has no presence bit. Its typed defaults must not turn holes into matches. + val searchesForTypedDefault = searchElement == falseExpr || + (searchElement is KFp64Value && searchElement.value == 0.0) + val searchesForUndefined = call.callee.name in setOf("indexOf", "lastIndexOf") && + searchElement == mkUndefinedValue() + if ( + array.hasFakeValueBranch() || receiverType?.dimensions != 1 || + searchesForTypedDefault || searchesForUndefined + ) { falseExpr } else { state.memory.types.evalIsSubtype(array, receiverType) @@ -47,14 +56,29 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } - private val optionalFromIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> - when { - call.arguments.size == 1 -> inputs + state.ctx.mkFp64(0.0) - call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> - inputs.dropLast(1) + state.ctx.mkFp64(0.0) + private val optionalFromIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) - call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs - else -> null + call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + } + + private val optionalLastIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 -> inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } } } @@ -74,6 +98,11 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { methodName = "includes", inputAdapter = optionalFromIndexAdapter, ), + sourceModel( + id = "ts.array.lastIndexOf", + methodName = "lastIndexOf", + inputAdapter = optionalLastIndexAdapter, + ), ) } @@ -102,4 +131,11 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return artifact.copy(entryPoint = entryPoint) } + + private fun TsUnknownCall.resolvedInstanceInputs(): List>? { + val resolvedReceiver = receiver?.resolved ?: return null + val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } + + return listOf(resolvedReceiver) + resolvedArguments + } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 188021ed0..232feb8ab 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -45,30 +45,52 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { ) } - private val optionalIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> - when { - call.arguments.isEmpty() -> inputs + state.ctx.mkFp64(0.0) - call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> - inputs.dropLast(1) + state.ctx.mkFp64(0.0) - - call.arguments.size == 1 && inputs.last().sort == state.ctx.fp64Sort -> inputs - else -> null + private val optionalIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.isEmpty() -> inputs + state.ctx.mkFp64(0.0) + call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 1 && inputs.last().sort == state.ctx.fp64Sort -> inputs + else -> null + } + } + } + + private val optionalPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> + inputs + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFp64(0.0) + + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last().sort == state.ctx.fp64Sort -> inputs + + else -> null + } } } - private val optionalPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call, inputs -> - when { - call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> - inputs + state.ctx.mkFp64(0.0) + private val optionalEndPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + call.resolvedInstanceInputs()?.let { inputs -> + when { + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> + inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) - call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && - inputs.last() == state.ctx.mkUndefinedValue() -> - inputs.dropLast(1) + state.ctx.mkFp64(0.0) + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.dropLast(1) + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) - call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && - inputs.last().sort == state.ctx.fp64Sort -> inputs + call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && + inputs.last().sort == state.ctx.fp64Sort -> inputs - else -> null + else -> null + } } } @@ -102,13 +124,21 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val receiverAndConcreteIndexDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + if (inputs.getOrNull(1) !is KFp64Value) { + state.ctx.falseExpr + } else { + receiverDomain.evaluate(state, call, inputs) + } + } + override val models: List by lazy { listOf( sourceModel( id = "ts.string.charAt", methodName = "charAt", inputAdapter = optionalIndexAdapter, - domainGuard = receiverDomain, + domainGuard = receiverAndConcreteIndexDomain, ), sourceModel( id = "ts.string.indexOf", @@ -122,6 +152,30 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { inputAdapter = optionalPositionAdapter, domainGuard = receiverAndSearchDomain, ), + sourceModel( + id = "ts.string.charCodeAt", + methodName = "charCodeAt", + inputAdapter = optionalIndexAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.startsWith", + methodName = "startsWith", + inputAdapter = optionalPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.endsWith", + methodName = "endsWith", + inputAdapter = optionalEndPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), + sourceModel( + id = "ts.string.lastIndexOf", + methodName = "lastIndexOf", + inputAdapter = optionalEndPositionAdapter, + domainGuard = receiverAndSearchDomain, + ), primitiveModel( methodName = "length", arity = 1, @@ -300,6 +354,13 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { residualGuard = guard.takeUnless { it == state.ctx.trueExpr }?.let(state.ctx::mkNot), ) + private fun TsUnknownCall.resolvedInstanceInputs(): List>? { + val resolvedReceiver = receiver?.resolved ?: return null + val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } + + return listOf(resolvedReceiver) + resolvedArguments + } + private class StringPrimitiveModel( methodName: String, private val arity: Int, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index f16fa9fe7..3bd0626c3 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -192,9 +192,9 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return handleArrayConcat(stmt, instanceType, array) } - // Handle `Array.indexOf() method calls - if (expr.callee.name == "indexOf") { - return handleArrayIndexOfCall(stmt, instanceType, elementSort, array) + // Handle Array search and indexed access method calls. + if (expr.callee.name in setOf("indexOf", "lastIndexOf")) { + return handleArrayIndexSearchCall(stmt, instanceType, elementSort, array) } // Handle `Array.includes() method calls @@ -208,7 +208,16 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } } - if (instanceType is EtsStringType && expr.callee.name in setOf("charAt", "indexOf", "includes")) { + val modeledStringMethods = setOf( + "charAt", + "charCodeAt", + "endsWith", + "includes", + "indexOf", + "lastIndexOf", + "startsWith", + ) + if (instanceType is EtsStringType && expr.callee.name in modeledStringMethods) { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { return TsExprApproximationResult.NoApproximation @@ -230,7 +239,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( return TsExprApproximationResult.NoApproximation } -private fun TsExprResolver.handleArrayIndexOfCall( +private fun TsExprResolver.handleArrayIndexSearchCall( stmt: TsVirtualMethodCallStmt, instanceType: EtsArrayType, elementSort: USort, @@ -238,6 +247,10 @@ private fun TsExprResolver.handleArrayIndexOfCall( ): TsExprApproximationResult { val dispatcher = unknownCallDispatcher if (dispatcher !is TsUnknownCallModelDispatcher) { + if (stmt.call.callee.name != "indexOf") { + return TsExprApproximationResult.NoApproximation + } + return from(handleArrayIndexOf(stmt.call, instanceType, elementSort, array)) } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index eb8fc45ea..551e62961 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -65,6 +65,25 @@ export class ArrayModels { return false; } + static lastIndexOf(receiver: any[], searchElement: any, fromIndex: number): number { + const length = receiver.length; + if (length === 0 || fromIndex === -Infinity) { + return -1; + } + + const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + let index = start >= length ? length - 1 : (start >= 0 ? start : length + start); + while (index >= 0) { + if (receiver[index] === searchElement) { + return index; + } + + index--; + } + + return -1; + } + private static normalizeRelativeIndex(fromIndex: number, length: number): number { if (fromIndex !== fromIndex) { return 0; diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index 836a2428b..9c0cfdcfc 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -50,6 +50,91 @@ export class StringModels { return StringModels.indexOf(receiver, searchString, position) !== -1; } + static charCodeAt(receiver: string, index: number): number { + const length = StringModelPrimitives.length(receiver); + const integerIndex = StringModels.normalizeCharIndex(index, length); + if (integerIndex < 0 || integerIndex >= length) { + return NaN; + } + + return StringModelPrimitives.codeUnitAt(receiver, integerIndex); + } + + static startsWith(receiver: string, searchString: string, position: number): boolean { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const start = StringModels.normalizePosition(position, length); + if (start + searchLength > length) { + return false; + } + + let searchIndex = 0; + while (searchIndex < searchLength) { + if ( + StringModelPrimitives.codeUnitAt(receiver, start + searchIndex) !== + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + return false; + } + + searchIndex++; + } + + return true; + } + + static endsWith(receiver: string, searchString: string, endPosition: number): boolean { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + const end = StringModels.normalizePosition(endPosition, length); + const start = end - searchLength; + if (start < 0) { + return false; + } + + let searchIndex = 0; + while (searchIndex < searchLength) { + if ( + StringModelPrimitives.codeUnitAt(receiver, start + searchIndex) !== + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + return false; + } + + searchIndex++; + } + + return true; + } + + static lastIndexOf(receiver: string, searchString: string, position: number): number { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(searchString); + let index = StringModels.normalizeLastPosition(position, length); + if (index + searchLength > length) { + index = length - searchLength; + } + + while (index >= 0) { + let searchIndex = 0; + while ( + searchIndex < searchLength && + StringModelPrimitives.codeUnitAt(receiver, index + searchIndex) === + StringModelPrimitives.codeUnitAt(searchString, searchIndex) + ) { + searchIndex++; + } + + if (searchIndex === searchLength) { + return index; + } + + index--; + } + + return -1; + } + private static normalizeCharIndex(value: number, length: number): number { if (value !== value) { return 0; @@ -97,4 +182,21 @@ export class StringModels { return integer; } + + private static normalizeLastPosition(value: number, length: number): number { + if (value !== value || value === Infinity || value >= length) { + return length; + } + + if (value === -Infinity || value <= 0) { + return 0; + } + + let integer = 0; + while (integer + 1 <= value) { + integer++; + } + + return integer; + } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index 8cd97b35f..45e9cec30 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -43,6 +43,14 @@ class TsSequenceEtsIrModelTest { assertEquals(listOf("ts.array.includes"), result.modelIds.distinct()) } + @Test + fun `typed default searches fall back without array presence metadata`() { + val result = analyze(methodName = "numericDefaultSearchFallsBack") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + @Test fun `array offsets normalize fractions and NaN`() { val result = analyze(methodName = "arrayOffsetsAreNormalized") @@ -65,6 +73,29 @@ class TsSequenceEtsIrModelTest { assertEquals(0.0, assertIs(result.values.single()).number) } + @Test + fun `array lastIndexOf searches backward from normalized offsets`() { + val result = analyze(methodName = "arrayLastIndexOfHandlesOffsets") + + assertEquals(199.0, assertIs(result.values.single()).number) + assertTrue("ts.array.lastIndexOf" in result.modelIds) + } + + @Test + fun `array lastIndexOf distinguishes omitted and undefined offsets`() { + val result = analyze(methodName = "arrayLastIndexOfExplicitUndefined") + + assertEquals(0.0, assertIs(result.values.single()).number) + } + + @Test + fun `numeric holes do not match zero`() { + val result = analyze(methodName = "numericHoleDoesNotMatchZero") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + @Test fun `array includes finds explicit undefined`() { val result = analyze(methodName = "explicitUndefinedArrayIncludesUndefined") @@ -125,6 +156,38 @@ class TsSequenceEtsIrModelTest { assertTrue("ts.string.indexOf" in result.modelIds) } + @Test + fun `symbolic charAt falls back until string value equality is modeled`() { + val result = analyze(methodName = "symbolicCharAt") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `string charCodeAt returns code units and NaN out of bounds`() { + val result = analyze(methodName = "stringCharCodeAtHandlesBounds") + + assertEquals(91.0, assertIs(result.values.single()).number) + assertTrue("ts.string.charCodeAt" in result.modelIds) + } + + @Test + fun `string startsWith and endsWith honor positions`() { + val result = analyze(methodName = "stringStartsAndEndsWithHandlePositions") + + assertTrue(assertIs(result.values.single()).value) + assertTrue(setOf("ts.string.startsWith", "ts.string.endsWith").all(result.modelIds::contains)) + } + + @Test + fun `string lastIndexOf searches backward and matches empty suffix`() { + val result = analyze(methodName = "stringLastIndexOfHandlesPositions") + + assertEquals(315.0, assertIs(result.values.single()).number) + assertTrue("ts.string.lastIndexOf" in result.modelIds) + } + private fun analyze(methodName: String): AnalysisResult { val method = method(methodName) val observer = RecordingUnknownCallObserver() diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index 511eb4771..b46e3a4b1 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -154,6 +154,7 @@ class TsUnknownCallModelCatalogTest { val expectedModelIds = listOf( "ts.array.includes", "ts.array.indexOf", + "ts.array.lastIndexOf", "ts.array.pop", TsArrayShiftIntrinsicModel.MODEL_ID, TsNumericIntrinsicModelFamily.MATH_ABS_ID, @@ -163,11 +164,15 @@ class TsUnknownCallModelCatalogTest { TsNumericIntrinsicModelFamily.MATH_ROUND_ID, TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, "ts.string.charAt", + "ts.string.charCodeAt", + "ts.string.endsWith", "ts.string.includes", "ts.string.indexOf", + "ts.string.lastIndexOf", "ts.string.primitive.codeUnitAt", "ts.string.primitive.fromCodeUnit", "ts.string.primitive.length", + "ts.string.startsWith", ) assertEquals(expectedModelIds, catalog.modelIds) diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts index ca3e99488..80762e1a1 100644 --- a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -11,7 +11,11 @@ export class SequenceEtsIr { } arrayIncludesUsesSameValueZero(): boolean { - return [NaN].includes(NaN) && ![NaN].includes(0); + return [NaN].includes(NaN); + } + + numericDefaultSearchFallsBack(): boolean { + return [0].includes(0); } arrayOffsetsAreNormalized(): number { @@ -29,6 +33,22 @@ export class SequenceEtsIr { return [1].indexOf(1, undefined); } + arrayLastIndexOfHandlesOffsets(): number { + const values = [1, 2, 1]; + return values.lastIndexOf(1) * 100 + + values.lastIndexOf(1, -2) * 10 + + values.lastIndexOf(1, -Infinity); + } + + arrayLastIndexOfExplicitUndefined(): number { + return [1, 2, 1].lastIndexOf(1, undefined); + } + + numericHoleDoesNotMatchZero(): number { + const values = new Array(1); + return values.indexOf(0); + } + explicitUndefinedArrayIncludesUndefined(): boolean { const values = [undefined]; return values.includes(undefined); @@ -63,4 +83,26 @@ export class SequenceEtsIr { if (position === 4) return "ababa".indexOf("ba", position); return -100; } + + symbolicCharAt(position: number): string { + return "abc".charAt(position); + } + + stringCharCodeAtHandlesBounds(): number { + const outside = "AZ".charCodeAt(2); + return "AZ".charCodeAt(1) + (outside !== outside ? 1 : 0); + } + + stringStartsAndEndsWithHandlePositions(): boolean { + return "abc".startsWith("b", 1) + && "abc".startsWith("", Infinity) + && "abc".endsWith("b", 2) + && "abc".endsWith("c", undefined); + } + + stringLastIndexOfHandlesPositions(): number { + return "ababa".lastIndexOf("ba") * 100 + + "ababa".lastIndexOf("ba", 2) * 10 + + "ababa".lastIndexOf("", Infinity); + } } From b4a22f70d2d0e845c0c7b035cbad63e5a14c6695 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:51:34 +0300 Subject: [PATCH 09/33] Expand semantic model dependencies --- .../machine/call/TsEtsIrUnknownCallModel.kt | 3 ++ .../usvm/machine/call/TsUnknownCallModel.kt | 2 ++ .../machine/call/TsUnknownCallModelCatalog.kt | 17 ++++++++++- .../intrinsic/TsStringEtsIrModelFamily.kt | 8 +++++ .../machine/call/TsSequenceEtsIrModelTest.kt | 28 ++++++++++++++++-- .../call/TsUnknownCallModelCatalogTest.kt | 29 +++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt index 66dea8409..a55c40d2e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt @@ -112,8 +112,10 @@ class TsEtsIrUnknownCallModel( val artifact: TsEtsIrUnknownCallModelArtifact, val domainGuard: TsEtsIrUnknownCallModelDomainGuard = TsEtsIrUnknownCallModelDomainGuard.ALWAYS, val inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, + requiredModelIds: Set = emptySet(), ) : TsUnknownCallModel, TsMachineLocalUnknownCallModel { override val additionalSceneFiles: List = listOf(artifact.file) + override val requiredModelIds: Set = requiredModelIds.toSet() override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { val inputs = inputAdapter.adapt( @@ -159,6 +161,7 @@ class TsEtsIrUnknownCallModel( artifact = materializedArtifact, domainGuard = domainGuard, inputAdapter = inputAdapter, + requiredModelIds = requiredModelIds, ) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt index 15d6f5c33..60feb720d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModel.kt @@ -32,6 +32,8 @@ data class TsUnknownCallTarget( interface TsUnknownCallModel { val id: String val target: TsUnknownCallTarget + val requiredModelIds: Set + get() = emptySet() /** EtsIR files that must be visible to the interpreter while this model is enabled. */ val additionalSceneFiles: List diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt index ff7db78c3..c55afe025 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalog.kt @@ -23,13 +23,28 @@ class TsUnknownCallModelCatalog( require(model.id.isNotBlank()) { "Semantic model ID must not be blank" } require(modelsById.put(model.id, model) == null) { "Duplicate semantic model ID: ${model.id}" } } + modelsById.values.forEach { model -> + val missingDependencies = model.requiredModelIds.subtract(modelsById.keys) + require(missingDependencies.isEmpty()) { + "Semantic model ${model.id} requires unknown model IDs: ${missingDependencies.sorted().joinToString()}" + } + } selectedModels = when (selection) { TsUnknownCallModelSelection.All -> modelsById.values is TsUnknownCallModelSelection.Only -> { val unknownIds = selection.ids.subtract(modelsById.keys) require(unknownIds.isEmpty()) { "Unknown semantic model IDs: ${unknownIds.sorted().joinToString()}" } - selection.ids.map(modelsById::getValue) + val expandedIds = linkedSetOf() + fun addWithDependencies(id: String) { + if (!expandedIds.add(id)) { + return + } + + modelsById.getValue(id).requiredModelIds.sorted().forEach(::addWithDependencies) + } + selection.ids.sorted().forEach(::addWithDependencies) + expandedIds.map(modelsById::getValue) } }.sortedBy(TsUnknownCallModel::id) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 232feb8ab..60597e885 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -209,6 +209,10 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { artifact = artifact(methodName), domainGuard = domainGuard, inputAdapter = inputAdapter, + requiredModelIds = when (methodName) { + "charAt" -> setOf(PRIMITIVE_LENGTH_ID, PRIMITIVE_CODE_UNIT_AT_ID, PRIMITIVE_FROM_CODE_UNIT_ID) + else -> setOf(PRIMITIVE_LENGTH_ID, PRIMITIVE_CODE_UNIT_AT_ID) + }, ) private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { @@ -382,4 +386,8 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return implementation(state, inputs) } } + + private const val PRIMITIVE_LENGTH_ID = "ts.string.primitive.length" + private const val PRIMITIVE_CODE_UNIT_AT_ID = "ts.string.primitive.codeUnitAt" + private const val PRIMITIVE_FROM_CODE_UNIT_ID = "ts.string.primitive.fromCodeUnit" } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index 45e9cec30..e84b92d03 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -120,6 +120,27 @@ class TsSequenceEtsIrModelTest { assertTrue(result.events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) } + @Test + fun `selecting charAt also enables its primitives`() { + val result = analyze( + methodName = "stringCharAtHandlesBounds", + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf("ts.string.charAt")), + ), + ) + + assertEquals("b", assertIs(result.values.single()).value) + assertEquals( + setOf( + "ts.string.charAt", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.fromCodeUnit", + "ts.string.primitive.length", + ), + result.modelIds.toSet(), + ) + } + @Test fun `string indexOf handles offsets and empty search`() { val result = analyze(methodName = "stringIndexOfHandlesOffsetsAndEmptySearch") @@ -188,14 +209,17 @@ class TsSequenceEtsIrModelTest { assertTrue("ts.string.lastIndexOf" in result.modelIds) } - private fun analyze(methodName: String): AnalysisResult { + private fun analyze( + methodName: String, + tsOptions: TsOptions = TsOptions(), + ): AnalysisResult { val method = method(methodName) val observer = RecordingUnknownCallObserver() return TsMachine( scene = scene, options = machineOptions, - tsOptions = TsOptions(), + tsOptions = tsOptions, observer = observer, ).use { machine -> val states = machine.analyze(listOf(method)) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index b46e3a4b1..d2d30677a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -81,6 +81,32 @@ class TsUnknownCallModelCatalogTest { assertEquals("Unknown semantic model IDs: missing", error.message) } + @Test + fun `selection includes transitive model dependencies`() { + val catalog = TsUnknownCallModelCatalog( + models = listOf( + model(id = "entry", requiredModelIds = setOf("helper")), + model(id = "helper", requiredModelIds = setOf("primitive")), + model(id = "primitive"), + model(id = "unrelated"), + ), + selection = TsUnknownCallModelSelection.Only(setOf("entry")), + ) + + assertEquals(listOf("entry", "helper", "primitive"), catalog.modelIds) + } + + @Test + fun `missing model dependency is rejected`() { + val error = assertFailsWith { + TsUnknownCallModelCatalog( + models = listOf(model(id = "entry", requiredModelIds = setOf("missing"))), + ) + } + + assertEquals("Semantic model entry requires unknown model IDs: missing", error.message) + } + @Test fun `selection does not depend on model order`() { val forward = listOf( @@ -287,6 +313,7 @@ class TsUnknownCallModelCatalogTest { failureReason: TsUnknownCallFailureReason? = null, className: String? = null, additionalSceneFiles: List = emptyList(), + requiredModelIds: Set = emptySet(), ): TsUnknownCallModel = FakeModel( id = id, target = TsUnknownCallTarget( @@ -295,12 +322,14 @@ class TsUnknownCallModelCatalogTest { enclosingClassName = className, ), additionalSceneFiles = additionalSceneFiles, + requiredModelIds = requiredModelIds, ) private class FakeModel( override val id: String, override val target: TsUnknownCallTarget, override val additionalSceneFiles: List = emptyList(), + override val requiredModelIds: Set = emptySet(), ) : TsUnknownCallModel { override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution = error("Fake model must not execute in catalog metadata tests") From 0826fab3882ae29d4631fbbb34d14fc189c7ff0b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:51:57 +0300 Subject: [PATCH 10/33] Bound sequence offset normalization --- .../usvm/machine/call/models/ArrayModels.ts | 13 +-------- .../usvm/machine/call/models/StringModels.ts | 27 +++---------------- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 551e62961..4d6aec15b 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -97,17 +97,6 @@ export class ArrayModels { return -length; } - let integer = 0; - if (fromIndex > 0) { - while (integer + 1 <= fromIndex) { - integer++; - } - } else { - while (integer - 1 >= fromIndex) { - integer--; - } - } - - return integer; + return fromIndex < 0 ? -Math.floor(-fromIndex) : Math.floor(fromIndex); } } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index 9c0cfdcfc..b464a89de 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -148,18 +148,7 @@ export class StringModels { return -length; } - let integer = 0; - if (value > 0) { - while (integer + 1 <= value) { - integer++; - } - } else { - while (integer - 1 >= value) { - integer--; - } - } - - return integer; + return value < 0 ? -Math.floor(-value) : Math.floor(value); } private static normalizePosition(value: number, length: number): number { @@ -175,12 +164,7 @@ export class StringModels { return 0; } - let integer = 0; - while (integer + 1 <= value) { - integer++; - } - - return integer; + return Math.floor(value); } private static normalizeLastPosition(value: number, length: number): number { @@ -192,11 +176,6 @@ export class StringModels { return 0; } - let integer = 0; - while (integer + 1 <= value) { - integer++; - } - - return integer; + return Math.floor(value); } } From 635026ae7319110d77daa0a5ba7a7047b8e67dfa Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:52:56 +0300 Subject: [PATCH 11/33] Add adjacent numeric intrinsic models --- .../intrinsic/TsNumericIntrinsicModels.kt | 83 ++++++++++++++++- .../usvm/machine/expr/CallApproximations.kt | 10 ++- .../call/TsNumericIntrinsicModelsTest.kt | 66 ++++++++++++++ .../call/TsUnknownCallModelCatalogTest.kt | 6 ++ .../models/NumericIntrinsicModels.ts | 88 +++++++++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt index 3296d166c..23f853103 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt @@ -4,6 +4,7 @@ import io.ksmt.expr.KFpRoundingMode import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsLocal +import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason @@ -17,10 +18,16 @@ import org.usvm.machine.state.TsState internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily { const val MATH_ABS_ID: String = "ts.math.abs" const val MATH_CEIL_ID: String = "ts.math.ceil" + const val MATH_FLOOR_ID: String = "ts.math.floor" const val MATH_MAX_ID: String = "ts.math.max" const val MATH_MIN_ID: String = "ts.math.min" const val MATH_ROUND_ID: String = "ts.math.round" + const val MATH_SQRT_ID: String = "ts.math.sqrt" + const val MATH_TRUNC_ID: String = "ts.math.trunc" + const val NUMBER_IS_FINITE_ID: String = "ts.number.isFinite" const val NUMBER_IS_INTEGER_ID: String = "ts.number.isInteger" + const val NUMBER_IS_NAN_ID: String = "ts.number.isNaN" + const val NUMBER_IS_SAFE_INTEGER_ID: String = "ts.number.isSafeInteger" override val models: List = listOf( NumericIntrinsicModel( @@ -44,6 +51,11 @@ internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily } }, ), + NumericIntrinsicModel( + id = MATH_FLOOR_ID, + methodName = "floor", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardNegative), + ), NumericIntrinsicModel( id = MATH_MAX_ID, methodName = "max", @@ -73,10 +85,47 @@ internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily methodName = "round", implementation = { state, call -> unaryMathCall(state, call, state::mathRound) }, ), + NumericIntrinsicModel( + id = MATH_SQRT_ID, + methodName = "sqrt", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + state.ctx.mkFpSqrtExpr(state.ctx.fpRoundingModeSortDefaultValue(), value) + } + }, + ), + NumericIntrinsicModel( + id = MATH_TRUNC_ID, + methodName = "trunc", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardZero), + ), + NumericIntrinsicModel( + id = NUMBER_IS_FINITE_ID, + methodName = "isFinite", + implementation = { state, call -> + numberPredicate(state, call) { value -> + with(state.ctx) { + mkAnd(mkFpIsNaNExpr(value).not(), mkFpIsInfiniteExpr(value).not()) + } + } + }, + ), NumericIntrinsicModel( id = NUMBER_IS_INTEGER_ID, methodName = "isInteger", - implementation = ::numberIsInteger, + implementation = { state, call -> numberPredicate(state, call, state::isInteger) }, + ), + NumericIntrinsicModel( + id = NUMBER_IS_NAN_ID, + methodName = "isNaN", + implementation = { state, call -> + numberPredicate(state, call) { value -> state.ctx.mkFpIsNaNExpr(value) } + }, + ), + NumericIntrinsicModel( + id = NUMBER_IS_SAFE_INTEGER_ID, + methodName = "isSafeInteger", + implementation = { state, call -> numberPredicate(state, call, state::isSafeInteger) }, ), ) } @@ -92,7 +141,11 @@ private class NumericIntrinsicModel( implementation(state, call) } -private fun numberIsInteger(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { +private fun numberPredicate( + state: TsState, + call: TsUnknownCall, + predicate: (UExpr) -> UBoolExpr, +): TsUnknownCallModelExecution? { if (!call.hasGlobalOwner("Number")) { return null } @@ -101,9 +154,9 @@ private fun numberIsInteger(state: TsState, call: TsUnknownCall): TsUnknownCallM val result = with(state.ctx) { if (value.isFakeObject()) { val type = value.getFakeType(state.memory) - mkAnd(type.fpTypeExpr, state.isInteger(value.extractFp(state.memory))) + mkAnd(type.fpTypeExpr, predicate(value.extractFp(state.memory))) } else if (value.sort == fp64Sort) { - state.isInteger(value.asExpr(fp64Sort)) + predicate(value.asExpr(fp64Sort)) } else { falseExpr } @@ -112,6 +165,19 @@ private fun numberIsInteger(state: TsState, call: TsUnknownCall): TsUnknownCallM return state.normalExecution(result) } +private fun roundingMathCall( + roundingMode: KFpRoundingMode, +): (TsState, TsUnknownCall) -> TsUnknownCallModelExecution? = { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(roundingMode), + value = value, + ) + } + } +} + private fun numericTarget(methodName: String) = TsUnknownCallTarget( methodName = methodName, failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, @@ -184,6 +250,13 @@ private fun TsState.isInteger(value: UExpr) = with(ctx) { ) } +private fun TsState.isSafeInteger(value: UExpr) = with(ctx) { + mkAnd( + isInteger(value), + mkFpLessOrEqualExpr(mkFpAbsExpr(value), mkFp(MAX_SAFE_INTEGER, fp64Sort)), + ) +} + private fun TsState.mathMin( left: UExpr, right: UExpr, @@ -259,3 +332,5 @@ private fun TsState.mathRound(value: UExpr): UExpr = with( mkIte(preserveInput, value, signedRounded) } + +private const val MAX_SAFE_INTEGER: Double = 9_007_199_254_740_991.0 diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 3bd0626c3..a70ca8c58 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -58,12 +58,13 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( // Handle `Number` calls. if (expr.instance.name == "Number") { when (expr.callee.name) { - "isInteger" -> { + "isFinite", "isInteger", "isSafeInteger" -> { return tryDispatchNumericBuiltin(expr) ?: TsExprApproximationResult.NoApproximation } - "isNaN" -> return from(handleNumberIsNaN(expr)) + "isNaN" -> return tryDispatchNumericBuiltin(expr) + ?: from(handleNumberIsNaN(expr)) } } @@ -87,12 +88,13 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( // Handle `Math` method calls. if (expr.instance.name == "Math") { when (expr.callee.name) { - "abs", "ceil", "max", "min", "round" -> { + "abs", "ceil", "max", "min", "round", "sqrt", "trunc" -> { return tryDispatchNumericBuiltin(expr) ?: TsExprApproximationResult.NoApproximation } - "floor" -> return from(handleMathFloor(expr)) + "floor" -> return tryDispatchNumericBuiltin(expr) + ?: from(handleMathFloor(expr)) } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt index 2b1f4e266..e56aee262 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsNumericIntrinsicModelsTest.kt @@ -141,6 +141,72 @@ class TsNumericIntrinsicModelsTest { assertEquals(listOf(TsUnknownCallOutcome.PATH_STOPPED), result.events.map { event -> event.outcome }) } + @Test + fun `adjacent Math models preserve special values`() { + val expected = linkedMapOf( + "floorNegativeFraction" to numberToken(-2.0), + "floorNegativeZero" to numberToken(-0.0), + "floorInfinity" to numberToken(Double.POSITIVE_INFINITY), + "truncNegativeFraction" to numberToken(-1.0), + "truncNegativeSmall" to numberToken(-0.0), + "truncNaN" to NAN, + "sqrtFour" to numberToken(2.0), + "sqrtNegative" to NAN, + "sqrtNegativeZero" to numberToken(-0.0), + "sqrtInfinity" to numberToken(Double.POSITIVE_INFINITY), + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedToken) -> + val actual = assertIs(result.values.getValue(methodName).single()).number + + assertEquals(expectedToken, numberToken(actual), methodName) + } + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.MATH_FLOOR_ID, + TsNumericIntrinsicModelFamily.MATH_TRUNC_ID, + TsNumericIntrinsicModelFamily.MATH_SQRT_ID, + ), + result.modelIds.toSet(), + ) + } + + @Test + fun `adjacent Number predicates handle special and non number values`() { + val expected = linkedMapOf( + "finiteNumber" to true, + "finiteNaN" to false, + "finiteInfinity" to false, + "finiteBoolean" to false, + "nanNaN" to true, + "nanNumber" to false, + "nanBoolean" to false, + "safeIntegerMaximum" to true, + "safeIntegerAboveMaximum" to false, + "safeIntegerFraction" to false, + "safeIntegerInfinity" to false, + "safeIntegerBoolean" to false, + ) + + val result = analyze(expected.keys.toList()) + + expected.forEach { (methodName, expectedValue) -> + val actual = assertIs(result.values.getValue(methodName).single()).value + + assertEquals(expectedValue, actual, methodName) + } + assertEquals( + setOf( + TsNumericIntrinsicModelFamily.NUMBER_IS_FINITE_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_NAN_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_SAFE_INTEGER_ID, + ), + result.modelIds.toSet(), + ) + } + private fun analyze( methodNames: List, tsOptions: TsOptions = TsOptions(), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index d2d30677a..8b167083d 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -185,10 +185,16 @@ class TsUnknownCallModelCatalogTest { TsArrayShiftIntrinsicModel.MODEL_ID, TsNumericIntrinsicModelFamily.MATH_ABS_ID, TsNumericIntrinsicModelFamily.MATH_CEIL_ID, + TsNumericIntrinsicModelFamily.MATH_FLOOR_ID, TsNumericIntrinsicModelFamily.MATH_MAX_ID, TsNumericIntrinsicModelFamily.MATH_MIN_ID, TsNumericIntrinsicModelFamily.MATH_ROUND_ID, + TsNumericIntrinsicModelFamily.MATH_SQRT_ID, + TsNumericIntrinsicModelFamily.MATH_TRUNC_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_FINITE_ID, TsNumericIntrinsicModelFamily.NUMBER_IS_INTEGER_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_NAN_ID, + TsNumericIntrinsicModelFamily.NUMBER_IS_SAFE_INTEGER_ID, "ts.string.charAt", "ts.string.charCodeAt", "ts.string.endsWith", diff --git a/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts index 707a18713..a87c3beed 100644 --- a/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts +++ b/usvm-ts/src/test/resources/models/NumericIntrinsicModels.ts @@ -118,3 +118,91 @@ export function unsupportedAbsDomain(): number { // @ts-expect-error Domain fallback deliberately supplies a non-number. return Math.abs(true); } + +export function floorNegativeFraction(): number { + return Math.floor(-1.25); +} + +export function floorNegativeZero(): number { + return Math.floor(-0); +} + +export function floorInfinity(): number { + return Math.floor(1 / 0); +} + +export function truncNegativeFraction(): number { + return Math.trunc(-1.75); +} + +export function truncNegativeSmall(): number { + return Math.trunc(-0.25); +} + +export function truncNaN(): number { + return Math.trunc(0 / 0); +} + +export function sqrtFour(): number { + return Math.sqrt(4); +} + +export function sqrtNegative(): number { + return Math.sqrt(-1); +} + +export function sqrtNegativeZero(): number { + return Math.sqrt(-0); +} + +export function sqrtInfinity(): number { + return Math.sqrt(1 / 0); +} + +export function finiteNumber(): boolean { + return Number.isFinite(42); +} + +export function finiteNaN(): boolean { + return Number.isFinite(0 / 0); +} + +export function finiteInfinity(): boolean { + return Number.isFinite(1 / 0); +} + +export function finiteBoolean(): boolean { + return Number.isFinite(true); +} + +export function nanNaN(): boolean { + return Number.isNaN(0 / 0); +} + +export function nanNumber(): boolean { + return Number.isNaN(42); +} + +export function nanBoolean(): boolean { + return Number.isNaN(true); +} + +export function safeIntegerMaximum(): boolean { + return Number.isSafeInteger(9007199254740991); +} + +export function safeIntegerAboveMaximum(): boolean { + return Number.isSafeInteger(9007199254740992); +} + +export function safeIntegerFraction(): boolean { + return Number.isSafeInteger(1.5); +} + +export function safeIntegerInfinity(): boolean { + return Number.isSafeInteger(1 / 0); +} + +export function safeIntegerBoolean(): boolean { + return Number.isSafeInteger(true); +} From d1f242a5ec9bbd8dc4a2b26befaf8a3061bd87a3 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:56:19 +0300 Subject: [PATCH 12/33] Declare source model dependencies and domains --- usvm-ts/UNKNOWN_CALL_MODELS.md | 36 +++++++++++++++---- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 3 ++ .../intrinsic/TsStringEtsIrModelFamily.kt | 11 ++++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index b4506e3f6..afcba8c59 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -99,6 +99,7 @@ Every model implements `TsUnknownCallModel`: interface TsUnknownCallModel { val id: String val target: TsUnknownCallTarget + val requiredModelIds: Set fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? } @@ -127,6 +128,10 @@ The ID is used for configuration, observer events, and recursion prevention. Do Keep the same ID when an equivalent model moves from Kotlin to TypeScript. +Selecting models with `TsUnknownCallModelSelection.Only` expands `requiredModelIds` transitively and sorts the final +catalog by ID. This keeps a high-level source model usable when it calls helper models. Missing dependency IDs are +rejected while building the catalog; an explicitly empty selection remains empty. + ### Choosing a target `TsUnknownCallTarget` matches stable call metadata declaratively: @@ -146,11 +151,11 @@ a priority rule. The enabled model set is frozen and sorted by ID when the catal The target identifies a call family. State-dependent checks, such as the receiver's symbolic runtime type, belong in `apply` or in an EtsIR model's domain guard. -The built-in array targets intentionally combine the method name with `PARTIAL_APPROXIMATION` instead of a class name. -That failure reason is emitted only after the regular approximation path has classified the receiver as an -`EtsArrayType` using the normalized receiver's storage type. An `any` alias of a known array can satisfy that check; -a receiver without array-type evidence cannot. The model still validates the resolved receiver and array shape -before changing memory. Both models preserve the array's storage type, including reference and unresolved elements. +The built-in array targets combine the method name with `PARTIAL_APPROXIMATION`. Array and String methods with the +same name also use a canonical enclosing class at this boundary. The failure reason is emitted only after the regular +approximation path has classified the normalized receiver by its storage type. An `any` alias of a known array can +satisfy that check; a receiver without array-type evidence cannot. The model still validates the resolved receiver +and array shape before changing memory. ## Applicability and residual states @@ -228,8 +233,22 @@ Array indexing and `length` assignment use the receiver's storage type. Writing zero through the current length, within the configured array-size limit. Growth remains unsupported because the engine does not represent newly created holes; those paths are pruned. -The entry point must be static and have a non-empty body. Its parameter count must equal the resolved receiver plus -argument count. Unresolved inputs or an arity mismatch make the model not applicable. +`Array.pop`, `indexOf`, `includes`, and `lastIndexOf` share one source-model family. Search offsets accept numbers and +the standard omitted or explicit-`undefined` defaults; other dynamic coercions use fallback. Array memory has no slot +presence bit, so a hole can look like a typed default. Searches for `0` or `false` therefore use fallback, as do +`indexOf(undefined)` and `lastIndexOf(undefined)`. `includes(undefined)` remains sound because both a hole and an +explicit `undefined` are matches. Position normalization depends on `ts.math.floor`. + +The String source family implements `charAt`, `charCodeAt`, `indexOf`, `lastIndexOf`, `includes`, `startsWith`, and +`endsWith`. Its TypeScript algorithms depend on atomic length, UTF-16 code-unit read, and one-code-unit construction +models, plus `ts.math.floor` for positions. Current symbolic String parameters do not initialize backing character +storage, so receivers and search strings must be initialized concrete constants. `charAt` also requires a concrete +index because a dynamically constructed one-code-unit String does not yet participate in value-based String equality. +Numeric-result and predicate methods can still use symbolic numeric positions over concrete strings. + +The entry point must be static and have a non-empty body. After its input adapter handles optional arguments or drops +non-semantic namespace receivers, its parameter count must equal the adapted input count. Unresolved required inputs +or an arity mismatch make the model not applicable. The domain guard has three useful outcomes: @@ -305,6 +324,9 @@ type guard. Unknown calls made inside a TypeScript model body use the same catalog and fallback as the original program. This lets source models compose with other source models and intrinsics. +Declare every nested semantic-model call in `requiredModelIds`. A selection containing only the high-level API then +expands to its helpers before the machine scene is materialized. + The state tracks each active model ID together with its call-stack depth. If the same model would redirect recursively, lookup declines that redirection and fallback is applied instead of entering an infinite loop. diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index 82b57ffc4..dc8e99d05 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -120,6 +120,7 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { artifact = artifact(methodName), domainGuard = arrayDomain, inputAdapter = inputAdapter, + requiredModelIds = setOf(MATH_FLOOR_MODEL_ID).takeUnless { methodName == "pop" }.orEmpty(), ) private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { @@ -138,4 +139,6 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return listOf(resolvedReceiver) + resolvedArguments } + + private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 60597e885..f6b9f5245 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -209,9 +209,13 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { artifact = artifact(methodName), domainGuard = domainGuard, inputAdapter = inputAdapter, - requiredModelIds = when (methodName) { - "charAt" -> setOf(PRIMITIVE_LENGTH_ID, PRIMITIVE_CODE_UNIT_AT_ID, PRIMITIVE_FROM_CODE_UNIT_ID) - else -> setOf(PRIMITIVE_LENGTH_ID, PRIMITIVE_CODE_UNIT_AT_ID) + requiredModelIds = buildSet { + add(MATH_FLOOR_MODEL_ID) + add(PRIMITIVE_LENGTH_ID) + add(PRIMITIVE_CODE_UNIT_AT_ID) + if (methodName == "charAt") { + add(PRIMITIVE_FROM_CODE_UNIT_ID) + } }, ) @@ -390,4 +394,5 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private const val PRIMITIVE_LENGTH_ID = "ts.string.primitive.length" private const val PRIMITIVE_CODE_UNIT_AT_ID = "ts.string.primitive.codeUnitAt" private const val PRIMITIVE_FROM_CODE_UNIT_ID = "ts.string.primitive.fromCodeUnit" + private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" } From 24acd4d6efa9504f7ebde8ed9ec608d7530e0502 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:57:53 +0300 Subject: [PATCH 13/33] Model numeric Date APIs in TypeScript --- usvm-ts/UNKNOWN_CALL_MODELS.md | 17 + .../main/kotlin/org/usvm/machine/TsContext.kt | 1 + .../main/kotlin/org/usvm/machine/TsMachine.kt | 1 + .../main/kotlin/org/usvm/machine/TsOptions.kt | 18 +- .../org/usvm/machine/call/TsUnknownCall.kt | 47 +- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 267 +++++++++ .../usvm/machine/expr/CallApproximations.kt | 3 +- .../kotlin/org/usvm/machine/expr/ReadField.kt | 19 +- .../org/usvm/machine/expr/WriteField.kt | 13 +- .../usvm/machine/call/models/DateModels.ts | 548 ++++++++++++++++++ .../usvm/machine/call/TsDateEtsIrModelTest.kt | 114 ++++ .../machine/call/TsDateModelsArtifactTest.kt | 32 + .../call/TsUnknownCallModelCatalogTest.kt | 11 +- .../src/test/resources/models/DateEtsIr.ts | 36 ++ .../resources/models/DateModelsComparison.mjs | 104 ++++ 15 files changed, 1201 insertions(+), 30 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt create mode 100644 usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt create mode 100644 usvm-ts/src/test/resources/models/DateEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/DateModelsComparison.mjs diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index afcba8c59..3a00d8723 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -284,6 +284,23 @@ Widening a local from `number[]` to `any[]` therefore keeps the same element and In contrast, `Array.pop` is expressed as the TypeScript body shown above. +### Date experiment boundary + +The built-in Date family keeps Gregorian calendar arithmetic, component overflow, leap years, and TimeClip in +`DateModels.ts`. Kotlin only routes calls, injects the experiment clock, and exposes the model's numeric timestamp +slot on a Date receiver. + +The current experiment has these explicit limits: + +- local getters, setters, and numeric component constructors use UTC, so `getTimezoneOffset()` returns zero and DST + behavior is outside the model domain; +- `Date.now()` and `new Date()` require `TsOptions.dateNowMilliseconds`; one fixed value is reused throughout the + analysis, and both calls use fallback when it is absent; +- one-argument construction supports numeric timestamps only; string parsing and copying another Date are outside + the model domain; +- symbolic string formatting is not claimed: `toISOString()` is a source implementation for supported concrete + execution, while symbolic string conversion remains subject to the engine's string limitations. + Good intrinsic candidates include: - bulk symbolic-memory copy or fill; diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt index e58f45f47..8f7d26207 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt @@ -61,6 +61,7 @@ class TsContext( val scene: EtsScene, components: TsComponents, internal val applicationAndSdkClasses: List = scene.projectAndSdkClasses, + internal val dateNowMilliseconds: Double? = null, ) : UContext(components) { val undefinedSort: TsUndefinedSort by lazy { TsUndefinedSort(this) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index 5c5d344b3..d6e5f7b59 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -85,6 +85,7 @@ class TsMachine( scene = analysisScene, components = components, applicationAndSdkClasses = scene.projectAndSdkClasses, + dateNowMilliseconds = tsOptions.dateNowMilliseconds, ) private val resolvedUnknownCallDispatcher = unknownCallDispatcher ?: TsModelUnknownCallDispatcher( models = requireNotNull(resolvedUnknownCallModels), diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt index aca3a3d54..a6b54d2d4 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsOptions.kt @@ -9,4 +9,20 @@ data class TsOptions( val maxArraySize: Int = 1_000, val unknownCallModelSelection: TsUnknownCallModelSelection = TsUnknownCallModelSelection.All, val unknownCallFallback: TsResidualCallPolicy = TsResidualCallPolicy.STOP_PATH, -) + /** Fixed experiment clock used by `Date.now()` and `new Date()`; `null` leaves those calls unsupported. */ + val dateNowMilliseconds: Double? = null, +) { + init { + val isValidDateNow = dateNowMilliseconds == null || + dateNowMilliseconds.isFinite() && + dateNowMilliseconds % 1.0 == 0.0 && + dateNowMilliseconds in -DATE_TIME_CLIP_BOUND_MILLIS..DATE_TIME_CLIP_BOUND_MILLIS + require(isValidDateNow) { + "The fixed Date clock must be an integral TimeClip-range millisecond timestamp" + } + } + + private companion object { + const val DATE_TIME_CLIP_BOUND_MILLIS = 8_640_000_000_000_000.0 + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index a46c7d9d6..c802d730c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -1,11 +1,14 @@ package org.usvm.machine.call import org.jacodb.ets.model.EtsCallExpr +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsInstanceCallExpr import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType import org.jacodb.ets.model.EtsValue import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UExpr @@ -141,19 +144,39 @@ internal fun TsUnknownCallDispatcher.dispatch( is EtsPtrCallExpr -> call.ptr else -> null } - return dispatch( - scope, - TsUnknownCall( - callee = callee, - receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, - arguments = call.args.zip(resolvedArguments) { source, resolved -> - TsUnknownCallValue(source, resolved) - }, - resultType = call.type, - callSite = callSite, - failureReason = failureReason, - ), + val normalizedCallee = call.canonicalizeDateCallee(callee) + val unknownCall = TsUnknownCall( + callee = normalizedCallee, + receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, + arguments = call.args.zip(resolvedArguments) { source, resolved -> + TsUnknownCallValue(source, resolved) + }, + resultType = call.type, + callSite = callSite, + failureReason = failureReason, ) + + return dispatch(scope, unknownCall) +} + +internal fun EtsInstanceCallExpr.hasDateReceiver(): Boolean = + instance.name == "Date" || when (val type = instance.type) { + is EtsClassType -> type.signature.name == "Date" + is EtsUnclearRefType -> type.typeName == "Date" + else -> false + } + +private fun EtsCallExpr.canonicalizeDateCallee(callee: EtsMethodSignature): EtsMethodSignature { + if (callee.enclosingClass.name == "Date") { + return callee + } + + val instanceCall = this as? EtsInstanceCallExpr ?: return callee + if (!instanceCall.hasDateReceiver()) { + return callee + } + + return callee.copy(enclosingClass = EtsClassSignature.UNKNOWN.copy(name = "Date")) } internal fun TsUnknownCallDispatcher.dispatch( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt new file mode 100644 index 000000000..42d1b094b --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -0,0 +1,267 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.utils.CONSTRUCTOR_NAME +import org.usvm.UExpr +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.state.TsState + +/** Numeric `Date` API family implemented by an ordinary TypeScript source body. */ +internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + private const val DATE_CLASS = "Date" + private const val MAX_CONSTRUCTOR_ARGUMENTS = 7 + private const val RESOURCE = "/org/usvm/machine/call/models/DateModels.ts" + + private val artifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE, + sourceFileName = "DateModels.ts", + entryPointClassName = "DateModels", + entryPointMethodName = "construct", + ) + } + + override val models: List by lazy { + buildList { + add(constructorModel()) + add(staticArityModel("utc", "UTC", "utc", minArgs = 0, maxArgs = 7)) + add(nowModel()) + + for (methodName in listOf( + "getDate", + "getDay", + "getFullYear", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getSeconds", + "getTime", + "getTimezoneOffset", + "getUTCDate", + "getUTCDay", + "getUTCFullYear", + "getUTCHours", + "getUTCMilliseconds", + "getUTCMinutes", + "getUTCMonth", + "getUTCSeconds", + "toISOString", + "valueOf", + )) { + add(instanceModel(methodName, methodName, consumedArgs = 0)) + } + + add(instanceModel("set-date", "setDate", consumedArgs = 1)) + add(instanceArityModel("set-full-year", "setFullYear", "setFullYear", minArgs = 1, maxArgs = 3)) + add(instanceArityModel("set-hours", "setHours", "setHours", minArgs = 1, maxArgs = 4)) + add(instanceModel("set-milliseconds", "setMilliseconds", consumedArgs = 1)) + add(instanceArityModel("set-minutes", "setMinutes", "setMinutes", minArgs = 1, maxArgs = 3)) + add(instanceArityModel("set-month", "setMonth", "setMonth", minArgs = 1, maxArgs = 2)) + add(instanceArityModel("set-seconds", "setSeconds", "setSeconds", minArgs = 1, maxArgs = 2)) + add(instanceModel("set-time", "setTime", consumedArgs = 1)) + add(instanceModel("set-utc-date", "setUTCDate", consumedArgs = 1)) + add(instanceArityModel("set-utc-full-year", "setUTCFullYear", "setUTCFullYear", minArgs = 1, maxArgs = 3)) + add(instanceArityModel("set-utc-hours", "setUTCHours", "setUTCHours", minArgs = 1, maxArgs = 4)) + add(instanceArityModel("set-utc-month", "setUTCMonth", "setUTCMonth", minArgs = 1, maxArgs = 2)) + add(instanceArityModel("set-utc-seconds", "setUTCSeconds", "setUTCSeconds", minArgs = 1, maxArgs = 2)) + } + } + + private fun constructorModel(): TsUnknownCallModel = model( + idSuffix = "constructor", + methodName = CONSTRUCTOR_NAME, + entryPointName = "construct", + domainGuard = instanceGuard(minArgs = 0, numericArgs = MAX_CONSTRUCTOR_ARGUMENTS), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val nowMilliseconds = dateNowMilliseconds + if (call.arguments.isEmpty() && nowMilliseconds == null) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + val arguments = call.resolvedArguments(maxArgs = MAX_CONSTRUCTOR_ARGUMENTS)?.toMutableList() + ?: return@TsEtsIrUnknownCallModelInputAdapter null + while (arguments.size < MAX_CONSTRUCTOR_ARGUMENTS) { + arguments += mkFp64(0.0) + } + val argumentCount = mkFp64(call.arguments.size.toDouble()) + val clock = mkFp64(nowMilliseconds ?: 0.0) + + listOf( + receiver, + argumentCount, + clock, + ) + arguments + } + }, + ) + + private fun nowModel(): TsUnknownCallModel = model( + idSuffix = "now", + methodName = "now", + entryPointName = "now", + domainGuard = numericGuard(minArgs = 0, numericArgs = 0), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, _ -> + with(state.ctx) { + listOf(mkFp64(dateNowMilliseconds ?: return@TsEtsIrUnknownCallModelInputAdapter null)) + } + }, + ) + + private fun instanceModel( + idSuffix: String, + methodName: String, + consumedArgs: Int, + ): TsUnknownCallModel = model( + idSuffix = idSuffix, + methodName = methodName, + entryPointName = methodName, + domainGuard = instanceGuard(minArgs = 0, numericArgs = consumedArgs), + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = call.resolvedArguments(consumedArgs) + ?: return@TsEtsIrUnknownCallModelInputAdapter null + listOf(receiver) + arguments + }, + ) + + private fun instanceArityModel( + idSuffix: String, + methodName: String, + entryPointName: String, + minArgs: Int, + maxArgs: Int, + ): TsUnknownCallModel = model( + idSuffix = idSuffix, + methodName = methodName, + entryPointName = entryPointName, + domainGuard = instanceGuard(minArgs = minArgs, numericArgs = maxArgs), + inputAdapter = arityAdapter(hasReceiver = true, maxArgs = maxArgs), + ) + + private fun staticArityModel( + idSuffix: String, + methodName: String, + entryPointName: String, + minArgs: Int, + maxArgs: Int, + ): TsUnknownCallModel = model( + idSuffix = idSuffix, + methodName = methodName, + entryPointName = entryPointName, + domainGuard = numericGuard(minArgs = minArgs, numericArgs = maxArgs), + inputAdapter = staticArityAdapter(maxArgs = maxArgs), + ) + + private fun model( + idSuffix: String, + methodName: String, + entryPointName: String, + domainGuard: TsEtsIrUnknownCallModelDomainGuard, + inputAdapter: TsEtsIrUnknownCallModelInputAdapter, + ) = TsEtsIrUnknownCallModel( + id = "ts.date.$idSuffix", + target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = DATE_CLASS, + ), + artifact = artifact.withEntryPoint(entryPointName), + domainGuard = domainGuard, + inputAdapter = inputAdapter, + ) + + private fun instanceGuard( + minArgs: Int, + numericArgs: Int, + ) = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + with(state.ctx) { + val receiver = call.receiver + val receiverValue = receiver?.resolved + if ( + call.callee.enclosingClass.name != DATE_CLASS || + receiverValue?.sort != addressSort || + receiverValue.asExpr(addressSort).hasFakeValueBranch() + ) { + falseExpr + } else if (!call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state)) { + falseExpr + } else { + trueExpr + } + } + } + + private fun numericGuard( + minArgs: Int, + numericArgs: Int, + ) = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + if (call.hasNumericArguments(minArgs = minArgs, numericArgs = numericArgs, state = state)) { + state.ctx.trueExpr + } else { + state.ctx.falseExpr + } + } + + private fun arityAdapter( + hasReceiver: Boolean, + maxArgs: Int, + ) = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val receiver = if (hasReceiver) { + listOf(call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null) + } else { + emptyList() + } + val arguments = call.resolvedArguments(maxArgs)?.toMutableList() + ?: return@TsEtsIrUnknownCallModelInputAdapter null + while (arguments.size < maxArgs) { + arguments += mkFp64(0.0) + } + + receiver + mkFp64(call.arguments.size.toDouble()) + arguments + } + } + + private fun staticArityAdapter(maxArgs: Int) = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val arguments = call.resolvedArguments(maxArgs)?.toMutableList() + ?: return@TsEtsIrUnknownCallModelInputAdapter null + while (arguments.size < maxArgs) { + arguments += mkFp64(0.0) + } + + listOf(mkFp64(call.arguments.size.toDouble())) + arguments + } + } + + private fun TsUnknownCall.resolvedArguments(maxArgs: Int): List>? = + arguments.take(maxArgs).map { argument -> + argument.resolved ?: return null + } + + private fun TsUnknownCall.hasNumericArguments( + minArgs: Int, + numericArgs: Int, + state: TsState, + ): Boolean { + if (arguments.size < minArgs) { + return false + } + + return arguments.take(numericArgs).all { argument -> argument.resolved?.sort == state.ctx.fp64Sort } + } + + private fun TsEtsIrUnknownCallModelArtifact.withEntryPoint(methodName: String): TsEtsIrUnknownCallModelArtifact { + val modelClass = file.allClasses.single { it.name == "DateModels" } + val method = modelClass.methods.single { it.name == methodName } + return copy(entryPoint = method) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index a70ca8c58..2a89582cd 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -26,6 +26,7 @@ import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModelDispatcher import org.usvm.machine.call.dispatch +import org.usvm.machine.call.hasDateReceiver import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved @@ -140,7 +141,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } // Handle `.valueOf()` method calls - if (expr.callee.name == "valueOf") { + if (expr.callee.name == "valueOf" && !expr.hasDateReceiver()) { return from(handleValueOf(expr, instance)) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt index fd27d6f2c..d063db251 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt @@ -83,13 +83,15 @@ fun TsContext.readField( is TsResolutionResult.Ambiguous -> unresolvedSort } - scope.doWithState { - // If we accessed some field, we make an assumption that - // this field should present in the object. - // That's not true in the common case for TS, but that's the decision we made. - val auxiliaryType = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.assert(memory.types.evalIsSubtype(instance, auxiliaryType)) + if (!field.isDateModelTimestamp()) { + scope.doWithState { + // If we accessed some field, we make an assumption that + // this field should present in the object. + // That's not true in the common case for TS, but that's the decision we made. + val auxiliaryType = EtsAuxiliaryType(properties = setOf(field.name)) + // assert is required to update models + scope.assert(memory.types.evalIsSubtype(instance, auxiliaryType)) + } } // If the field type is known, we can read it directly. @@ -121,6 +123,9 @@ fun TsContext.readField( } } +private fun EtsFieldSignature.isDateModelTimestamp(): Boolean = + enclosingClass.name == "DateValue" && name == "timestamp" + internal fun TsExprResolver.handleStaticFieldRef( value: EtsStaticFieldRef, ): UExpr<*>? = with(ctx) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 2a465cc79..2273ad30f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -143,10 +143,12 @@ fun TsContext.assignToInstanceField( val etsField = resolveEtsField(instanceLocal, field, hierarchy) // If we access some field, we expect that the object must have this field. // It is not always true for TS, but we decided to process it so. - val supertype = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.doWithState { - scope.assert(memory.types.evalIsSubtype(unwrappedInstance, supertype)) + if (!field.isDateModelTimestamp()) { + val supertype = EtsAuxiliaryType(properties = setOf(field.name)) + // assert is required to update models + scope.doWithState { + scope.assert(memory.types.evalIsSubtype(unwrappedInstance, supertype)) + } } // Determine the field sort. @@ -196,6 +198,9 @@ fun TsContext.assignToInstanceField( } } +private fun EtsFieldSignature.isDateModelTimestamp(): Boolean = + enclosingClass.name == "DateValue" && name == "timestamp" + internal fun TsExprResolver.handleAssignToStaticField( lhv: EtsStaticFieldRef, expr: UExpr<*>, diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts new file mode 100644 index 000000000..b0d31201e --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -0,0 +1,548 @@ +// Numeric Date semantic model. Local-time operations intentionally use UTC. + +export class DateValue { + timestamp: number = NaN; +} + +class DateParts { + year: number; + month: number; + date: number; + day: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; + + constructor( + year: number, + month: number, + date: number, + day: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ) { + this.year = year; + this.month = month; + this.date = date; + this.day = day; + this.hours = hours; + this.minutes = minutes; + this.seconds = seconds; + this.milliseconds = milliseconds; + } +} + +export class DateModels { + private static readonly MS_PER_SECOND = 1_000; + private static readonly MS_PER_MINUTE = 60_000; + private static readonly MS_PER_HOUR = 3_600_000; + private static readonly MS_PER_DAY = 86_400_000; + private static readonly MAX_TIME = 8_640_000_000_000_000; + + static construct( + receiver: DateValue, + argumentCount: number, + nowMilliseconds: number, + yearOrTimestamp: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): DateValue { + if (argumentCount === 0) { + receiver.timestamp = DateModels.timeClip(nowMilliseconds); + return receiver; + } + + if (argumentCount === 1) { + receiver.timestamp = DateModels.timeClip(yearOrTimestamp); + return receiver; + } + + receiver.timestamp = DateModels.makeDate( + DateModels.normalizeConstructorYear(yearOrTimestamp), + month, + argumentCount >= 3 ? date : 1, + argumentCount >= 4 ? hours : 0, + argumentCount >= 5 ? minutes : 0, + argumentCount >= 6 ? seconds : 0, + argumentCount >= 7 ? milliseconds : 0, + ); + return receiver; + } + + static utc( + argumentCount: number, + year: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + if (argumentCount < 2) { + return NaN; + } + + return DateModels.makeDate( + DateModels.normalizeConstructorYear(year), + month, + argumentCount >= 3 ? date : 1, + argumentCount >= 4 ? hours : 0, + argumentCount >= 5 ? minutes : 0, + argumentCount >= 6 ? seconds : 0, + argumentCount >= 7 ? milliseconds : 0, + ); + } + + static now(nowMilliseconds: number): number { + return DateModels.timeClip(nowMilliseconds); + } + + static getDate(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).date; + } + + static getDay(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).day; + } + + static getFullYear(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).year; + } + + static getHours(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).hours; + } + + static getMilliseconds(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).milliseconds; + } + + static getMinutes(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).minutes; + } + + static getMonth(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).month; + } + + static getSeconds(receiver: DateValue): number { + return DateModels.parts(receiver.timestamp).seconds; + } + + static getTime(receiver: DateValue): number { + return receiver.timestamp; + } + + static getTimezoneOffset(_receiver: DateValue): number { + return 0; + } + + static getUTCDate(receiver: DateValue): number { + return DateModels.getDate(receiver); + } + + static getUTCDay(receiver: DateValue): number { + return DateModels.getDay(receiver); + } + + static getUTCFullYear(receiver: DateValue): number { + return DateModels.getFullYear(receiver); + } + + static getUTCHours(receiver: DateValue): number { + return DateModels.getHours(receiver); + } + + static getUTCMilliseconds(receiver: DateValue): number { + return DateModels.getMilliseconds(receiver); + } + + static getUTCMinutes(receiver: DateValue): number { + return DateModels.getMinutes(receiver); + } + + static getUTCMonth(receiver: DateValue): number { + return DateModels.getMonth(receiver); + } + + static getUTCSeconds(receiver: DateValue): number { + return DateModels.getSeconds(receiver); + } + + static setDate(receiver: DateValue, date: number): number { + return DateModels.setDateFields(receiver, 1, date, 0, 0); + } + + static setFullYear( + receiver: DateValue, + argumentCount: number, + year: number, + month: number, + date: number, + ): number { + const current = DateModels.partsOrEpoch(receiver.timestamp); + return DateModels.replaceDate( + receiver, + year, + argumentCount >= 2 ? month : current.month, + argumentCount >= 3 ? date : current.date, + current, + ); + } + + static setHours( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setTimeFields(receiver, argumentCount, hours, minutes, seconds, milliseconds, 0); + } + + static setMilliseconds(receiver: DateValue, milliseconds: number): number { + return DateModels.setTimeFields(receiver, 4, 0, 0, 0, milliseconds, 3); + } + + static setMinutes( + receiver: DateValue, + argumentCount: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setTimeFields(receiver, argumentCount + 1, 0, minutes, seconds, milliseconds, 1); + } + + static setMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { + return DateModels.setDateFields(receiver, argumentCount + 1, 0, month, date); + } + + static setSeconds(receiver: DateValue, argumentCount: number, seconds: number, milliseconds: number): number { + return DateModels.setTimeFields(receiver, argumentCount + 2, 0, 0, seconds, milliseconds, 2); + } + + static setTime(receiver: DateValue, timestamp: number): number { + receiver.timestamp = DateModels.timeClip(timestamp); + return receiver.timestamp; + } + + static setUTCDate(receiver: DateValue, date: number): number { + return DateModels.setDate(receiver, date); + } + + static setUTCFullYear( + receiver: DateValue, + argumentCount: number, + year: number, + month: number, + date: number, + ): number { + return DateModels.setFullYear(receiver, argumentCount, year, month, date); + } + + static setUTCHours( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setHours(receiver, argumentCount, hours, minutes, seconds, milliseconds); + } + + static setUTCMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { + return DateModels.setMonth(receiver, argumentCount, month, date); + } + + static setUTCSeconds(receiver: DateValue, argumentCount: number, seconds: number, milliseconds: number): number { + return DateModels.setSeconds(receiver, argumentCount, seconds, milliseconds); + } + + static toISOString(receiver: DateValue): string { + const parts = DateModels.parts(receiver.timestamp); + if (DateModels.isInvalid(parts.year)) { + throw new RangeError("Invalid time value"); + } + + return DateModels.formatYear(parts.year) + "-" + + DateModels.pad2(parts.month + 1) + "-" + + DateModels.pad2(parts.date) + "T" + + DateModels.pad2(parts.hours) + ":" + + DateModels.pad2(parts.minutes) + ":" + + DateModels.pad2(parts.seconds) + "." + + DateModels.pad3(parts.milliseconds) + "Z"; + } + + static valueOf(receiver: DateValue): number { + return receiver.timestamp; + } + + private static setDateFields( + receiver: DateValue, + argumentCount: number, + first: number, + second: number, + third: number, + ): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + if (argumentCount === 1) { + return DateModels.replaceDate(receiver, current.year, current.month, first, current); + } + + return DateModels.replaceDate( + receiver, + current.year, + second, + argumentCount >= 3 ? third : current.date, + current, + ); + } + + private static setTimeFields( + receiver: DateValue, + argumentCount: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + firstField: number, + ): number { + if (DateModels.isInvalid(receiver.timestamp)) { + return DateModels.invalidate(receiver); + } + + const current = DateModels.parts(receiver.timestamp); + const nextHours = firstField === 0 ? hours : current.hours; + const nextMinutes = firstField <= 1 && argumentCount >= 2 ? minutes : current.minutes; + const nextSeconds = firstField <= 2 && argumentCount >= 3 ? seconds : current.seconds; + const nextMilliseconds = argumentCount >= 4 ? milliseconds : current.milliseconds; + + receiver.timestamp = DateModels.makeDate( + current.year, + current.month, + current.date, + nextHours, + nextMinutes, + nextSeconds, + nextMilliseconds, + ); + return receiver.timestamp; + } + + private static replaceDate( + receiver: DateValue, + year: number, + month: number, + date: number, + time: DateParts, + ): number { + receiver.timestamp = DateModels.makeDate( + year, + month, + date, + time.hours, + time.minutes, + time.seconds, + time.milliseconds, + ); + return receiver.timestamp; + } + + private static makeDate( + year: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + year = DateModels.toInteger(year); + month = DateModels.toInteger(month); + date = DateModels.toInteger(date); + hours = DateModels.toInteger(hours); + minutes = DateModels.toInteger(minutes); + seconds = DateModels.toInteger(seconds); + milliseconds = DateModels.toInteger(milliseconds); + + if ( + DateModels.isInvalid(year) || DateModels.isInvalid(month) || DateModels.isInvalid(date) || + DateModels.isInvalid(hours) || DateModels.isInvalid(minutes) || DateModels.isInvalid(seconds) || + DateModels.isInvalid(milliseconds) + ) { + return NaN; + } + + const normalizedYear = year + DateModels.floorDiv(month, 12); + const normalizedMonth = DateModels.mod(month, 12); + const days = DateModels.daysFromCivil(normalizedYear, normalizedMonth, date); + const timestamp = days * DateModels.MS_PER_DAY + hours * DateModels.MS_PER_HOUR + + minutes * DateModels.MS_PER_MINUTE + seconds * DateModels.MS_PER_SECOND + milliseconds; + return DateModels.timeClip(timestamp); + } + + private static parts(timestamp: number): DateParts { + if (DateModels.isInvalid(timestamp)) { + return new DateParts(NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN); + } + + const normalizedTimestamp = timestamp === 0 ? 0 : timestamp; + const days = DateModels.floorDiv(normalizedTimestamp, DateModels.MS_PER_DAY); + let withinDay = normalizedTimestamp - days * DateModels.MS_PER_DAY; + const hours = DateModels.floorDiv(withinDay, DateModels.MS_PER_HOUR); + withinDay -= hours * DateModels.MS_PER_HOUR; + const minutes = DateModels.floorDiv(withinDay, DateModels.MS_PER_MINUTE); + withinDay -= minutes * DateModels.MS_PER_MINUTE; + const seconds = DateModels.floorDiv(withinDay, DateModels.MS_PER_SECOND); + const milliseconds = withinDay - seconds * DateModels.MS_PER_SECOND; + + const civil = DateModels.civilFromDays(days); + return new DateParts( + civil.year, + civil.month, + civil.date, + DateModels.mod(days + 4, 7), + hours, + minutes, + seconds, + milliseconds, + ); + } + + private static partsOrEpoch(timestamp: number): DateParts { + return DateModels.isInvalid(timestamp) ? DateModels.parts(0) : DateModels.parts(timestamp); + } + + private static daysFromCivil(year: number, month: number, date: number): number { + const adjustedYear = year - (month <= 1 ? 1 : 0); + const era = DateModels.floorDiv(adjustedYear, 400); + const yearOfEra = adjustedYear - era * 400; + const adjustedMonth = month + (month > 1 ? -2 : 10); + const dayOfYear = DateModels.floorDiv(153 * adjustedMonth + 2, 5) + date - 1; + const dayOfEra = yearOfEra * 365 + DateModels.floorDiv(yearOfEra, 4) - + DateModels.floorDiv(yearOfEra, 100) + dayOfYear; + return era * 146_097 + dayOfEra - 719_468; + } + + private static civilFromDays(days: number): DateParts { + const adjustedDays = days + 719_468; + const era = DateModels.floorDiv(adjustedDays, 146_097); + const dayOfEra = adjustedDays - era * 146_097; + const yearOfEra = DateModels.floorDiv( + dayOfEra - DateModels.floorDiv(dayOfEra, 1_460) + + DateModels.floorDiv(dayOfEra, 36_524) - DateModels.floorDiv(dayOfEra, 146_096), + 365, + ); + let year = yearOfEra + era * 400; + const dayOfYear = dayOfEra - ( + 365 * yearOfEra + DateModels.floorDiv(yearOfEra, 4) - DateModels.floorDiv(yearOfEra, 100) + ); + const monthPrime = DateModels.floorDiv(5 * dayOfYear + 2, 153); + const date = dayOfYear - DateModels.floorDiv(153 * monthPrime + 2, 5) + 1; + const month = monthPrime + (monthPrime < 10 ? 2 : -10); + year += month <= 1 ? 1 : 0; + return new DateParts(year, month, date, 0, 0, 0, 0, 0); + } + + private static normalizeConstructorYear(year: number): number { + year = DateModels.toInteger(year); + return year >= 0 && year <= 99 ? year + 1900 : year; + } + + private static timeClip(timestamp: number): number { + if (DateModels.isInvalid(timestamp) || timestamp > DateModels.MAX_TIME || timestamp < -DateModels.MAX_TIME) { + return NaN; + } + + const clipped = DateModels.toInteger(timestamp); + return clipped === 0 ? 0 : clipped; + } + + private static toInteger(value: number): number { + if (value === 0 || DateModels.isInvalid(value)) { + return value; + } + + const remainder = value % 1; + return value - remainder; + } + + private static floorDiv(dividend: number, divisor: number): number { + const remainder = dividend % divisor; + const quotient = (dividend - remainder) / divisor; + return remainder < 0 ? quotient - 1 : quotient; + } + + private static mod(dividend: number, divisor: number): number { + const remainder = dividend % divisor; + if (remainder === 0) { + return 0; + } + + return remainder < 0 ? remainder + divisor : remainder; + } + + private static isInvalid(value: number): boolean { + return value !== value; + } + + private static invalidate(receiver: DateValue): number { + receiver.timestamp = NaN; + return receiver.timestamp; + } + + private static digit(value: number): string { + if (value === 0) return "0"; + if (value === 1) return "1"; + if (value === 2) return "2"; + if (value === 3) return "3"; + if (value === 4) return "4"; + if (value === 5) return "5"; + if (value === 6) return "6"; + if (value === 7) return "7"; + if (value === 8) return "8"; + return "9"; + } + + private static pad2(value: number): string { + return DateModels.digit(DateModels.floorDiv(value, 10)) + DateModels.digit(DateModels.mod(value, 10)); + } + + private static pad3(value: number): string { + return DateModels.digit(DateModels.floorDiv(value, 100)) + + DateModels.pad2(DateModels.mod(value, 100)); + } + + private static pad4(value: number): string { + return DateModels.pad2(DateModels.floorDiv(value, 100)) + DateModels.pad2(DateModels.mod(value, 100)); + } + + private static pad6(value: number): string { + return DateModels.pad3(DateModels.floorDiv(value, 1_000)) + DateModels.pad3(DateModels.mod(value, 1_000)); + } + + private static formatYear(year: number): string { + if (year >= 0 && year <= 9_999) { + return DateModels.pad4(year); + } + + const sign = year < 0 ? "-" : "+"; + const absoluteYear = year < 0 ? -year : year; + return sign + DateModels.pad6(absoluteYear); + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt new file mode 100644 index 000000000..26180ecc1 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -0,0 +1,114 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsDateEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/DateEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `Date now and zero argument constructor share the configured fixed clock`() { + val method = method("fixedClock") + val states = analyze( + method = method, + tsOptions = TsOptions(dateNowMilliseconds = 1_710_067_696_789.0), + ) + val value = TsTestResolver().resolve(method, states.single()).returnValue + + assertEquals(0.0, assertIs(value).number) + } + + @Test + fun `clock dependent Date calls remain unsupported without an explicit clock`() { + assertTrue(analyze(method("fixedClock")).isEmpty()) + } + + @Test + fun `numeric constructors getters UTC and overflow execute through source models`() { + assertNumber(methodName = "epochYear", expected = 1970.0) + assertNumber(methodName = "leapDay", expected = 129.0) + assertNumber(methodName = "overflow", expected = 20_231_201.0) + } + + @Test + fun `concrete ISO formatting executes through the source model`() { + val method = method("isoEpoch") + val value = TsTestResolver().resolve(method, analyze(method).single()).returnValue + + assertEquals("1970-01-01T00:00:00.000Z", assertIs(value).value) + } + + @Test + fun `setter updates the shared Date timestamp slot`() { + assertNumber(methodName = "setter", expected = 951_782_400_029.0) + } + + @Test + fun `symbolic numeric timestamp round trips through constructor and valueOf`() { + val method = method("symbolicRoundTrip") + val states = analyze(method) + + assertTrue(states.isNotEmpty()) + val allResultsAreNumbers = states.all { state -> + val result = assertIs(TsTestResolver().resolve(method, state).returnValue) + result.number.isFinite() || result.number.isNaN() + } + + assertTrue(allResultsAreNumbers) + } + + private fun assertNumber(methodName: String, expected: Double) { + val method = method(methodName) + val values = analyze(method).map { state -> TsTestResolver().resolve(method, state).returnValue } + + assertEquals(expected, assertIs(values.single()).number) + } + + private fun analyze( + method: EtsMethod, + tsOptions: TsOptions = TsOptions(), + ) = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = tsOptions, + ).use { machine -> machine.analyze(listOf(method)) } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "DateEtsIr" } + .methods + .single { it.name == name } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt new file mode 100644 index 000000000..ff2ded3ff --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateModelsArtifactTest.kt @@ -0,0 +1,32 @@ +package org.usvm.machine.call + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TsDateModelsArtifactTest { + @Test + fun `built in catalog registers Date family`() { + val ids = TsBuiltInUnknownCallModels.catalog().modelIds + + assertTrue("ts.date.constructor" in ids, ids.toString()) + assertTrue("ts.date.getTime" in ids, ids.toString()) + assertTrue("ts.date.now" in ids, ids.toString()) + } + + @Test + fun `native frontend loads Date source model family`() { + val artifact = loadBundledEtsIrUnknownCallModelArtifact( + resourceName = "/org/usvm/machine/call/models/DateModels.ts", + sourceFileName = "DateModels.ts", + entryPointClassName = "DateModels", + entryPointMethodName = "construct", + ) + val dateModels = artifact.file.allClasses.single { it.name == "DateModels" } + + assertEquals("construct", artifact.entryPoint.name) + assertTrue(dateModels.methods.any { it.name == "getTime" }) + assertTrue(dateModels.methods.any { it.name == "toISOString" }) + assertTrue(dateModels.methods.all { method -> method.cfg.instructions.isNotEmpty() }) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index 8b167083d..95bd84ed0 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -207,13 +207,14 @@ class TsUnknownCallModelCatalogTest { "ts.string.startsWith", ) - assertEquals(expectedModelIds, catalog.modelIds) + assertTrue(catalog.modelIds.containsAll(expectedModelIds)) + assertTrue("ts.date.constructor" in catalog.modelIds) + assertTrue("ts.date.now" in catalog.modelIds) + assertEquals(expected = 36, actual = catalog.modelIds.count { it.startsWith("ts.date.") }) + assertEquals(catalog.modelIds.distinct().sorted(), catalog.modelIds) assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) assertFailsWith { (catalog.modelIds as MutableList).clear() } - assertEquals( - expectedModelIds, - TsBuiltInUnknownCallModels.catalog().modelIds, - ) + assertEquals(catalog.modelIds, TsBuiltInUnknownCallModels.catalog().modelIds) assertTrue(TsBuiltInUnknownCallModels.catalog(TsUnknownCallModelSelection.Only(emptySet())).modelIds.isEmpty()) } diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts new file mode 100644 index 000000000..8246bc0b4 --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class DateEtsIr { + fixedClock(): number { + return Date.now() - new Date().getTime(); + } + + epochYear(): number { + return new Date(0).getUTCFullYear(); + } + + isoEpoch(): string { + return new Date(0).toISOString(); + } + + leapDay(): number { + const date = new Date(Date.UTC(2000, 1, 29, 12, 34, 56, 789)); + return date.getUTCMonth() * 100 + date.getUTCDate(); + } + + overflow(): number { + const date = new Date(Date.UTC(2024, -1, 0, 25, -1, 0, 0)); + return date.getUTCFullYear() * 10_000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate(); + } + + setter(): number { + const date = new Date(0); + const timestamp = date.setUTCFullYear(2000, 1, 29); + return timestamp + date.getUTCDate(); + } + + symbolicRoundTrip(timestamp: number): number { + return new Date(timestamp).valueOf(); + } +} diff --git a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs new file mode 100644 index 000000000..1d709cf9f --- /dev/null +++ b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { DateModels, DateValue } from "../../../main/resources/org/usvm/machine/call/models/DateModels.ts"; + +function modeled(...arguments_) { + const receiver = new DateValue(); + DateModels.construct(receiver, arguments_.length, 0, ...arguments_, 0, 0, 0, 0, 0, 0, 0); + return receiver; +} + +function nativeResult(operation, arguments_) { + const date = new Date(...arguments_); + const result = operation(date); + return [result, date.getTime()]; +} + +function modelResult(operation, arguments_) { + const date = modeled(...arguments_); + const result = operation(date); + return [result, date.timestamp]; +} + +const timestamps = [ + -8_640_000_000_000_000, + -2_208_988_800_001, + -1, + -0, + 0, + 951_827_696_789, + 1_710_067_696_789, + 8_640_000_000_000_000, + 8_640_000_000_000_001, + NaN, +]; + +const getters = [ + ["getDate", (date) => date.getUTCDate(), DateModels.getDate], + ["getDay", (date) => date.getUTCDay(), DateModels.getDay], + ["getFullYear", (date) => date.getUTCFullYear(), DateModels.getFullYear], + ["getHours", (date) => date.getUTCHours(), DateModels.getHours], + ["getMilliseconds", (date) => date.getUTCMilliseconds(), DateModels.getMilliseconds], + ["getMinutes", (date) => date.getUTCMinutes(), DateModels.getMinutes], + ["getMonth", (date) => date.getUTCMonth(), DateModels.getMonth], + ["getSeconds", (date) => date.getUTCSeconds(), DateModels.getSeconds], + ["getTime", (date) => date.getTime(), DateModels.getTime], +]; + +for (const timestamp of timestamps) { + const receiver = modeled(timestamp); + const native = new Date(timestamp); + for (const [name, nativeGetter, modelGetter] of getters) { + assert.deepEqual(modelGetter(receiver), nativeGetter(native), `${name}(${timestamp})`); + } +} + +const componentCases = [ + [1970, 0], + [99, 11, 31, 23, 59, 59, 999], + [2000, 1, 29, 12, 34, 56, 789], + [1900, 1, 29], + [2024, -14, 0, -2, 120, -90, 2_001], + [-1, 0, 1], + [275760, 8, 13], +]; + +for (const arguments_ of componentCases) { + assert.deepEqual( + modeled(...arguments_).timestamp, + new Date(Date.UTC(...arguments_)).getTime(), + `constructor(${arguments_.join(",")})`, + ); + assert.deepEqual( + DateModels.utc(arguments_.length, ...arguments_, 0, 0, 0, 0, 0, 0, 0), + Date.UTC(...arguments_), + `UTC(${arguments_.join(",")})`, + ); +} + +const setterCases = [ + ["setDate", [0], (date, args) => date.setUTCDate(...args), (date, args) => DateModels.setDate(date, ...args)], + ["setFullYear", [2024, 13, 0], (date, args) => date.setUTCFullYear(...args), (date, args) => DateModels.setFullYear(date, args.length, ...args, 0, 0)], + ["setHours", [-1, 70, -80, 1_500], (date, args) => date.setUTCHours(...args), (date, args) => DateModels.setHours(date, args.length, ...args, 0, 0, 0)], + ["setMilliseconds", [-1], (date, args) => date.setUTCMilliseconds(...args), (date, args) => DateModels.setMilliseconds(date, ...args)], + ["setMinutes", [61, -2, 1_001], (date, args) => date.setUTCMinutes(...args), (date, args) => DateModels.setMinutes(date, args.length, ...args, 0, 0)], + ["setMonth", [-13, 40], (date, args) => date.setUTCMonth(...args), (date, args) => DateModels.setMonth(date, args.length, ...args, 0)], + ["setSeconds", [-61, 2_000], (date, args) => date.setUTCSeconds(...args), (date, args) => DateModels.setSeconds(date, args.length, ...args, 0)], + ["setTime", [-1.9], (date, args) => date.setTime(...args), (date, args) => DateModels.setTime(date, ...args)], +]; + +for (const timestamp of [-1, 0, 951_827_696_789]) { + for (const [name, args, nativeSetter, modelSetter] of setterCases) { + assert.deepEqual( + modelResult((date) => modelSetter(date, args), [timestamp]), + nativeResult((date) => nativeSetter(date, args), [timestamp]), + `${name} from ${timestamp}`, + ); + } +} + +for (const timestamp of [-62_167_219_200_000, -1, 0, 253_402_300_799_999]) { + const receiver = modeled(timestamp); + assert.equal(DateModels.toISOString(receiver), new Date(timestamp).toISOString()); +} + +console.log("DateModels comparison passed"); From d0b9f2d47eb3089ec60bea3009ed183c770d1cb6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 17:58:53 +0300 Subject: [PATCH 14/33] Guard sparse Array search domains --- usvm-ts/UNKNOWN_CALL_MODELS.md | 7 ++-- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 35 +++++++++++++------ .../usvm/machine/call/models/ArrayModels.ts | 9 ++++- .../machine/call/TsSequenceEtsIrModelTest.kt | 25 +++++++++++++ .../test/resources/models/SequenceEtsIr.ts | 14 ++++++++ 5 files changed, 76 insertions(+), 14 deletions(-) diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 3a00d8723..005d14b55 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -236,8 +236,11 @@ engine does not represent newly created holes; those paths are pruned. `Array.pop`, `indexOf`, `includes`, and `lastIndexOf` share one source-model family. Search offsets accept numbers and the standard omitted or explicit-`undefined` defaults; other dynamic coercions use fallback. Array memory has no slot presence bit, so a hole can look like a typed default. Searches for `0` or `false` therefore use fallback, as do -`indexOf(undefined)` and `lastIndexOf(undefined)`. `includes(undefined)` remains sound because both a hole and an -explicit `undefined` are matches. Position normalization depends on `ts.math.floor`. +`indexOf(undefined)` and `lastIndexOf(undefined)`. `includes(undefined)` is accepted only for address or unresolved +storage; numeric and boolean storage use fallback because their holes currently read as typed defaults. Symbolic +numeric and boolean search values use a guarded model branch outside the typed default and residual fallback on the +unsupported default. Fake-wrapped dynamic search values use fallback. Position normalization depends on +`ts.math.floor`. The String source family implements `charAt`, `charCodeAt`, `indexOf`, `lastIndexOf`, `includes`, `startsWith`, and `endsWith`. Its TypeScript algorithms depend on atomic length, UTF-16 code-unit read, and one-code-unit construction diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index dc8e99d05..1fdf527c5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -1,6 +1,5 @@ package org.usvm.machine.call.intrinsic -import io.ksmt.expr.KFp64Value import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType import org.usvm.UExpr @@ -39,18 +38,32 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { val array = receiver.asExpr(addressSort) val receiverType = state.arrayStorageType(array, staticType) as? EtsArrayType val searchElement = inputs.getOrNull(1) - // Array storage has no presence bit. Its typed defaults must not turn holes into matches. - val searchesForTypedDefault = searchElement == falseExpr || - (searchElement is KFp64Value && searchElement.value == 0.0) - val searchesForUndefined = call.callee.name in setOf("indexOf", "lastIndexOf") && - searchElement == mkUndefinedValue() - if ( - array.hasFakeValueBranch() || receiverType?.dimensions != 1 || - searchesForTypedDefault || searchesForUndefined - ) { + val searchRef = searchElement + ?.takeIf { it.sort == addressSort } + ?.asExpr(addressSort) + if (array.hasFakeValueBranch() || receiverType?.dimensions != 1 || searchRef?.hasFakeValueBranch() == true) { falseExpr } else { - state.memory.types.evalIsSubtype(array, receiverType) + val elementSort = typeToSort(receiverType.elementType) + val excludesMissingSlot = when { + searchElement == mkUndefinedValue() && + (call.callee.name in setOf("indexOf", "lastIndexOf") || + elementSort == fp64Sort || elementSort == boolSort) -> falseExpr + + elementSort == fp64Sort && searchElement?.sort == fp64Sort -> mkNot( + mkFpEqualExpr(searchElement.asExpr(fp64Sort), mkFp64(0.0)) + ) + + elementSort == boolSort && searchElement?.sort == boolSort -> + searchElement.asExpr(boolSort) + + else -> trueExpr + } + + mkAnd( + state.memory.types.evalIsSubtype(array, receiverType), + excludesMissingSlot, + ) } } } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 4d6aec15b..0274b5e33 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -71,7 +71,14 @@ export class ArrayModels { return -1; } - const start = ArrayModels.normalizeRelativeIndex(fromIndex, length); + let start = fromIndex !== fromIndex ? 0 : fromIndex; + if (start !== Infinity) { + start = start < 0 ? -Math.floor(-start) : Math.floor(start); + } + if (start < -length) { + return -1; + } + let index = start >= length ? length - 1 : (start >= 0 ? start : length + start); while (index >= 0) { if (receiver[index] === searchElement) { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index e84b92d03..33e74d5db 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -88,6 +88,13 @@ class TsSequenceEtsIrModelTest { assertEquals(0.0, assertIs(result.values.single()).number) } + @Test + fun `array lastIndexOf stops before indexes below negative length`() { + val result = analyze(methodName = "arrayLastIndexOfBeforeStart") + + assertEquals(-1.0, assertIs(result.values.single()).number) + } + @Test fun `numeric holes do not match zero`() { val result = analyze(methodName = "numericHoleDoesNotMatchZero") @@ -96,6 +103,24 @@ class TsSequenceEtsIrModelTest { assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) } + @Test + fun `numeric holes reject includes undefined without presence metadata`() { + val result = analyze(methodName = "numericHoleDoesNotIncludeUndefined") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `symbolic typed-default search uses residual fallback`() { + val result = analyze(methodName = "numericHoleWithSymbolicSearch") + + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { value -> !assertIs(value).value }) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + assertTrue(result.events.any { it.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + @Test fun `array includes finds explicit undefined`() { val result = analyze(methodName = "explicitUndefinedArrayIncludesUndefined") diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts index 80762e1a1..ba06c10d5 100644 --- a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -44,11 +44,25 @@ export class SequenceEtsIr { return [1, 2, 1].lastIndexOf(1, undefined); } + arrayLastIndexOfBeforeStart(): number { + return [1].lastIndexOf(1, -2); + } + numericHoleDoesNotMatchZero(): number { const values = new Array(1); return values.indexOf(0); } + numericHoleDoesNotIncludeUndefined(): boolean { + const values = new Array(1); + return values.includes(undefined); + } + + numericHoleWithSymbolicSearch(value: number): boolean { + const values = new Array(1); + return values.includes(value); + } + explicitUndefinedArrayIncludesUndefined(): boolean { const values = [undefined]; return values.includes(undefined); From fdd06118aae22e6e803af52485e79e9a61253794 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:01:55 +0300 Subject: [PATCH 15/33] Canonicalize Array search zero results --- .../org/usvm/machine/call/models/ArrayModels.ts | 6 +++++- .../usvm/machine/call/TsSequenceEtsIrModelTest.kt | 14 +++++++++++--- usvm-ts/src/test/resources/models/SequenceEtsIr.ts | 9 +++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 0274b5e33..08daef0ba 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -74,6 +74,9 @@ export class ArrayModels { let start = fromIndex !== fromIndex ? 0 : fromIndex; if (start !== Infinity) { start = start < 0 ? -Math.floor(-start) : Math.floor(start); + if (start === 0) { + start = 0; + } } if (start < -length) { return -1; @@ -104,6 +107,7 @@ export class ArrayModels { return -length; } - return fromIndex < 0 ? -Math.floor(-fromIndex) : Math.floor(fromIndex); + const integer = fromIndex < 0 ? -Math.floor(-fromIndex) : Math.floor(fromIndex); + return integer === 0 ? 0 : integer; } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index 33e74d5db..e8d3ee296 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -32,7 +32,7 @@ class TsSequenceEtsIrModelTest { val result = analyze(methodName = "arrayIndexOfUsesStrictEqualityAndOffsets") assertEquals(-681.0, assertIs(result.values.single()).number) - assertEquals(listOf("ts.array.indexOf"), result.modelIds.distinct()) + assertEquals(listOf("ts.array.indexOf", "ts.math.floor"), result.modelIds.distinct()) } @Test @@ -40,7 +40,7 @@ class TsSequenceEtsIrModelTest { val result = analyze(methodName = "arrayIncludesUsesSameValueZero") assertTrue(assertIs(result.values.single()).value) - assertEquals(listOf("ts.array.includes"), result.modelIds.distinct()) + assertEquals(listOf("ts.array.includes", "ts.math.floor"), result.modelIds.distinct()) } @Test @@ -56,7 +56,7 @@ class TsSequenceEtsIrModelTest { val result = analyze(methodName = "arrayOffsetsAreNormalized") assertEquals(131.0, assertIs(result.values.single()).number) - assertEquals(setOf("ts.array.includes", "ts.array.indexOf"), result.modelIds.toSet()) + assertEquals(setOf("ts.array.includes", "ts.array.indexOf", "ts.math.floor"), result.modelIds.toSet()) } @Test @@ -95,6 +95,13 @@ class TsSequenceEtsIrModelTest { assertEquals(-1.0, assertIs(result.values.single()).number) } + @Test + fun `array searches canonicalize negative zero results`() { + val result = analyze(methodName = "arraySearchReturnsPositiveZero") + + assertEquals(3.0, assertIs(result.values.single()).number) + } + @Test fun `numeric holes do not match zero`() { val result = analyze(methodName = "numericHoleDoesNotMatchZero") @@ -158,6 +165,7 @@ class TsSequenceEtsIrModelTest { assertEquals( setOf( "ts.string.charAt", + "ts.math.floor", "ts.string.primitive.codeUnitAt", "ts.string.primitive.fromCodeUnit", "ts.string.primitive.length", diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts index ba06c10d5..0a34f1034 100644 --- a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -48,6 +48,15 @@ export class SequenceEtsIr { return [1].lastIndexOf(1, -2); } + arraySearchReturnsPositiveZero(): number { + const first = [1].indexOf(1, -0); + const last = [1].lastIndexOf(1, -0.9); + let result = 0; + if (1 / first === Infinity) result += 1; + if (1 / last === Infinity) result += 2; + return result; + } + numericHoleDoesNotMatchZero(): number { const values = new Array(1); return values.indexOf(0); From dbfcfa2f69ad7b096a0b0641d80a132c829788df Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:17:33 +0300 Subject: [PATCH 16/33] Require concrete String code units --- .../intrinsic/TsStringEtsIrModelFamily.kt | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index f6b9f5245..8affd26e5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -9,7 +9,6 @@ import org.jacodb.ets.model.EtsStringType import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.api.evalTypeEquals -import org.usvm.api.initializeArray import org.usvm.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard @@ -309,39 +308,14 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { state: TsState, inputs: List>, ): TsUnknownCallModelExecution? = with(state.ctx) { - val code = inputs.singleOrNull()?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val code = inputs.singleOrNull() as? KFp64Value ?: return null TsUnknownCallModelExecution( successors = listOf( TsUnknownCallModelSuccessor( guard = trueExpr, completion = TsUnknownCallModelCompletion.Normal { - if (code is KFp64Value) { - return@Normal mkInitializedStringConstant(code.value.toInt().toChar().toString()) - } - - val codeUnit = ctx.mkFpToBvExpr( - roundingMode = ctx.fpRoundingModeSortDefaultValue(), - value = code, - bvSize = ctx.bv16Sort.sizeBits.toInt(), - isSigned = false, - ).asExpr(ctx.bv16Sort) - val result = memory.allocConcrete(EtsStringType) - val characters = memory.allocConcrete(characterArrayType.elementType) - memory.initializeArray( - arrayHeapRef = characters, - type = ctx.arrayDescriptorOf(characterArrayType), - sort = ctx.bv16Sort, - sizeSort = ctx.sizeSort, - contents = sequenceOf(codeUnit), - ) - memory.write( - mkFieldLValue(ctx.addressSort, result, "value"), - characters, - guard = ctx.trueExpr, - ) - - result + mkInitializedStringConstant(code.value.toInt().toChar().toString()) }, ) ), From 284a6d282ffd82097bd44844970119c909cd2fbc Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:11:09 +0300 Subject: [PATCH 17/33] Handle Date UTC omissions and invalid timezone offsets --- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 40 ++++++++++++++++++- .../usvm/machine/call/models/DateModels.ts | 8 ++-- .../usvm/machine/call/TsDateEtsIrModelTest.kt | 20 ++++++++++ .../src/test/resources/models/DateEtsIr.ts | 20 ++++++++++ .../resources/models/DateModelsComparison.mjs | 21 ++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index 42d1b094b..6450bd699 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -31,7 +31,7 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { override val models: List by lazy { buildList { add(constructorModel()) - add(staticArityModel("utc", "UTC", "utc", minArgs = 0, maxArgs = 7)) + add(utcModel()) add(nowModel()) for (methodName in listOf( @@ -116,6 +116,34 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { }, ) + private fun utcModel(): TsUnknownCallModel = model( + idSuffix = "utc", + methodName = "UTC", + entryPointName = "utc", + domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, _ -> + if (call.hasNumericOrUndefinedArguments(maxArgs = MAX_CONSTRUCTOR_ARGUMENTS, state = state)) { + state.ctx.trueExpr + } else { + state.ctx.falseExpr + } + }, + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + with(state.ctx) { + val arguments = call.arguments.take(MAX_CONSTRUCTOR_ARGUMENTS).map { argument -> + when (val resolved = argument.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null) { + mkUndefinedValue() -> mkFp64NaN() + else -> resolved + } + }.toMutableList() + while (arguments.size < MAX_CONSTRUCTOR_ARGUMENTS) { + arguments += mkFp64(0.0) + } + + listOf(mkFp64(call.arguments.size.toDouble())) + arguments + } + }, + ) + private fun instanceModel( idSuffix: String, methodName: String, @@ -259,6 +287,16 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return arguments.take(numericArgs).all { argument -> argument.resolved?.sort == state.ctx.fp64Sort } } + private fun TsUnknownCall.hasNumericOrUndefinedArguments( + maxArgs: Int, + state: TsState, + ): Boolean = with(state.ctx) { + arguments.take(maxArgs).all { argument -> + val resolved = argument.resolved + resolved?.sort == fp64Sort || resolved == mkUndefinedValue() + } + } + private fun TsEtsIrUnknownCallModelArtifact.withEntryPoint(methodName: String): TsEtsIrUnknownCallModelArtifact { val modelClass = file.allClasses.single { it.name == "DateModels" } val method = modelClass.methods.single { it.name == methodName } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts index b0d31201e..c7b1d84ab 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -86,13 +86,13 @@ export class DateModels { seconds: number, milliseconds: number, ): number { - if (argumentCount < 2) { + if (argumentCount < 1) { return NaN; } return DateModels.makeDate( DateModels.normalizeConstructorYear(year), - month, + argumentCount >= 2 ? month : 0, argumentCount >= 3 ? date : 1, argumentCount >= 4 ? hours : 0, argumentCount >= 5 ? minutes : 0, @@ -141,8 +141,8 @@ export class DateModels { return receiver.timestamp; } - static getTimezoneOffset(_receiver: DateValue): number { - return 0; + static getTimezoneOffset(receiver: DateValue): number { + return DateModels.isInvalid(receiver.timestamp) ? NaN : 0; } static getUTCDate(receiver: DateValue): number { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index 26180ecc1..0a14266bd 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -50,6 +50,19 @@ class TsDateEtsIrModelTest { assertNumber(methodName = "overflow", expected = 20_231_201.0) } + @Test + fun `UTC distinguishes omitted arguments from explicit undefined`() { + assertNaN(methodName = "utcNoArguments") + assertNaN(methodName = "utcUndefinedYear") + assertNumber(methodName = "utcYearOnly", expected = 1_577_836_800_000.0) + assertNaN(methodName = "utcExplicitUndefined") + } + + @Test + fun `timezone offset of an invalid Date is NaN`() { + assertNaN(methodName = "invalidTimezoneOffset") + } + @Test fun `concrete ISO formatting executes through the source model`() { val method = method("isoEpoch") @@ -84,6 +97,13 @@ class TsDateEtsIrModelTest { assertEquals(expected, assertIs(values.single()).number) } + private fun assertNaN(methodName: String) { + val method = method(methodName) + val values = analyze(method).map { state -> TsTestResolver().resolve(method, state).returnValue } + + assertTrue(assertIs(values.single()).number.isNaN()) + } + private fun analyze( method: EtsMethod, tsOptions: TsOptions = TsOptions(), diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts index 8246bc0b4..6e7f65bfe 100644 --- a/usvm-ts/src/test/resources/models/DateEtsIr.ts +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -24,6 +24,26 @@ export class DateEtsIr { return date.getUTCFullYear() * 10_000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate(); } + utcNoArguments(): number { + return Date.UTC(); + } + + utcUndefinedYear(): number { + return Date.UTC(undefined); + } + + utcYearOnly(): number { + return Date.UTC(2020); + } + + utcExplicitUndefined(): number { + return Date.UTC(2020, undefined); + } + + invalidTimezoneOffset(): number { + return new Date(NaN).getTimezoneOffset(); + } + setter(): number { const date = new Date(0); const timestamp = date.setUTCFullYear(2000, 1, 29); diff --git a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs index 1d709cf9f..a7573b464 100644 --- a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs +++ b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs @@ -62,6 +62,21 @@ const componentCases = [ [275760, 8, 13], ]; +const utcBoundaryCases = [ + [], + [undefined], + [2020], + [2020, undefined], +]; + +for (const arguments_ of utcBoundaryCases) { + assert.deepEqual( + DateModels.utc(arguments_.length, ...arguments_, 0, 0, 0, 0, 0, 0, 0), + Date.UTC(...arguments_), + `UTC(${arguments_.join(",")})`, + ); +} + for (const arguments_ of componentCases) { assert.deepEqual( modeled(...arguments_).timestamp, @@ -101,4 +116,10 @@ for (const timestamp of [-62_167_219_200_000, -1, 0, 253_402_300_799_999]) { assert.equal(DateModels.toISOString(receiver), new Date(timestamp).toISOString()); } +assert.deepEqual( + DateModels.getTimezoneOffset(modeled(NaN)), + new Date(NaN).getTimezoneOffset(), + "getTimezoneOffset(NaN)", +); + console.log("DateModels comparison passed"); From c9890ede486d9fcb2c46b3bfad820a7822bc2427 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:12:15 +0300 Subject: [PATCH 18/33] Truncate Date fractions toward zero --- .../resources/org/usvm/machine/call/models/DateModels.ts | 5 +++-- .../kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt | 6 ++++++ usvm-ts/src/test/resources/models/DateEtsIr.ts | 8 ++++++++ .../src/test/resources/models/DateModelsComparison.mjs | 3 +++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts index c7b1d84ab..f288c708f 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -478,8 +478,9 @@ export class DateModels { return value; } - const remainder = value % 1; - return value - remainder; + return value < 0 + ? -DateModels.floorDiv(-value, 1) + : DateModels.floorDiv(value, 1); } private static floorDiv(dividend: number, divisor: number): number { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index 0a14266bd..452444fa2 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -63,6 +63,12 @@ class TsDateEtsIrModelTest { assertNaN(methodName = "invalidTimezoneOffset") } + @Test + fun `Date truncates fractional timestamps and components toward zero`() { + assertNumber(methodName = "fractionalTimestamps", expected = 9.0) + assertNumber(methodName = "fractionalUtcDay", expected = 1.0) + } + @Test fun `concrete ISO formatting executes through the source model`() { val method = method("isoEpoch") diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts index 6e7f65bfe..55b161196 100644 --- a/usvm-ts/src/test/resources/models/DateEtsIr.ts +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -44,6 +44,14 @@ export class DateEtsIr { return new Date(NaN).getTimezoneOffset(); } + fractionalTimestamps(): number { + return new Date(1.9).getTime() * 10 + new Date(-1.9).getTime(); + } + + fractionalUtcDay(): number { + return new Date(Date.UTC(2024, 0, 1.9)).getUTCDate(); + } + setter(): number { const date = new Date(0); const timestamp = date.setUTCFullYear(2000, 1, 29); diff --git a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs index a7573b464..e6609c1de 100644 --- a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs +++ b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs @@ -22,9 +22,11 @@ function modelResult(operation, arguments_) { const timestamps = [ -8_640_000_000_000_000, -2_208_988_800_001, + -1.9, -1, -0, 0, + 1.9, 951_827_696_789, 1_710_067_696_789, 8_640_000_000_000_000, @@ -58,6 +60,7 @@ const componentCases = [ [2000, 1, 29, 12, 34, 56, 789], [1900, 1, 29], [2024, -14, 0, -2, 120, -90, 2_001], + [2024, 0, 1.9], [-1, 0, 1], [275760, 8, 13], ]; From 563718b4df67cca417b8c92601756e094ee5ee1d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:16:08 +0300 Subject: [PATCH 19/33] Resolve Date calls through any aliases --- .../org/usvm/machine/call/TsUnknownCall.kt | 32 ++++++++++++++++--- .../usvm/machine/expr/CallApproximations.kt | 9 ++++-- .../usvm/machine/call/TsDateEtsIrModelTest.kt | 6 ++++ .../src/test/resources/models/DateEtsIr.ts | 10 ++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt index c802d730c..d3bbab1aa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsUnknownCall.kt @@ -1,5 +1,6 @@ package org.usvm.machine.call +import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsCallExpr import org.jacodb.ets.model.EtsClassSignature import org.jacodb.ets.model.EtsClassType @@ -13,11 +14,14 @@ import org.jacodb.ets.model.EtsValue import org.jacodb.ets.utils.CONSTRUCTOR_NAME import org.usvm.UExpr import org.usvm.api.mockMethodCall +import org.usvm.api.typeStreamOf import org.usvm.machine.TsConcreteMethodCallStmt import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState import org.usvm.machine.state.newStmt +import org.usvm.types.singleOrNull /** * A call that the regular TypeScript execution pipeline could not execute. @@ -144,7 +148,10 @@ internal fun TsUnknownCallDispatcher.dispatch( is EtsPtrCallExpr -> call.ptr else -> null } - val normalizedCallee = call.canonicalizeDateCallee(callee) + val receiverIsDate = (call as? EtsInstanceCallExpr)?.let { instanceCall -> + scope.calcOnState { isDateReceiver(instanceCall, resolvedReceiver) } + } ?: false + val normalizedCallee = call.canonicalizeDateCallee(callee, receiverIsDate) val unknownCall = TsUnknownCall( callee = normalizedCallee, receiver = receiverSource?.let { TsUnknownCallValue(it, resolvedReceiver) }, @@ -166,13 +173,30 @@ internal fun EtsInstanceCallExpr.hasDateReceiver(): Boolean = else -> false } -private fun EtsCallExpr.canonicalizeDateCallee(callee: EtsMethodSignature): EtsMethodSignature { +internal fun TsState.isDateReceiver( + call: EtsInstanceCallExpr, + receiver: UExpr<*>?, +): Boolean { + if (call.hasDateReceiver()) { + return true + } + if (receiver?.sort != ctx.addressSort) { + return false + } + + val runtimeType = memory.typeStreamOf(receiver.asExpr(ctx.addressSort)).singleOrNull() + return (runtimeType as? EtsClassType)?.signature?.name == "Date" +} + +private fun EtsCallExpr.canonicalizeDateCallee( + callee: EtsMethodSignature, + receiverIsDate: Boolean, +): EtsMethodSignature { if (callee.enclosingClass.name == "Date") { return callee } - val instanceCall = this as? EtsInstanceCallExpr ?: return callee - if (!instanceCall.hasDateReceiver()) { + if (!receiverIsDate) { return callee } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 2a89582cd..3728c3eef 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -26,7 +26,7 @@ import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModelDispatcher import org.usvm.machine.call.dispatch -import org.usvm.machine.call.hasDateReceiver +import org.usvm.machine.call.isDateReceiver import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.markResolved @@ -141,8 +141,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } // Handle `.valueOf()` method calls - if (expr.callee.name == "valueOf" && !expr.hasDateReceiver()) { - return from(handleValueOf(expr, instance)) + if (expr.callee.name == "valueOf") { + val receiverIsDate = scope.calcOnState { isDateReceiver(expr, instance) } + if (!receiverIsDate) { + return from(handleValueOf(expr, instance)) + } } if (instance.sort != addressSort) return TsExprApproximationResult.NoApproximation diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index 452444fa2..6dff3b05a 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -69,6 +69,12 @@ class TsDateEtsIrModelTest { assertNumber(methodName = "fractionalUtcDay", expected = 1.0) } + @Test + fun `Date calls through any aliases use the Date model`() { + assertNumber(methodName = "anyAliasValueOf", expected = 123.0) + assertNumber(methodName = "anyAliasGetTime", expected = 456.0) + } + @Test fun `concrete ISO formatting executes through the source model`() { val method = method("isoEpoch") diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts index 55b161196..48593b3cc 100644 --- a/usvm-ts/src/test/resources/models/DateEtsIr.ts +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -52,6 +52,16 @@ export class DateEtsIr { return new Date(Date.UTC(2024, 0, 1.9)).getUTCDate(); } + anyAliasValueOf(): number { + const date: any = new Date(123); + return date.valueOf(); + } + + anyAliasGetTime(): number { + const date: any = new Date(456); + return date.getTime(); + } + setter(): number { const date = new Date(0); const timestamp = date.setUTCFullYear(2000, 1, 29); From 33781b44f6db1ba4a9375aef1150dee55354c587 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:18:49 +0300 Subject: [PATCH 20/33] Model remaining UTC minute setters --- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 2 ++ .../org/usvm/machine/call/models/DateModels.ts | 14 ++++++++++++++ .../org/usvm/machine/call/TsDateEtsIrModelTest.kt | 5 +++++ .../machine/call/TsUnknownCallModelCatalogTest.kt | 2 +- usvm-ts/src/test/resources/models/DateEtsIr.ts | 7 +++++++ .../test/resources/models/DateModelsComparison.mjs | 2 ++ 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index 6450bd699..c0b3bfe60 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -70,6 +70,8 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { add(instanceModel("set-utc-date", "setUTCDate", consumedArgs = 1)) add(instanceArityModel("set-utc-full-year", "setUTCFullYear", "setUTCFullYear", minArgs = 1, maxArgs = 3)) add(instanceArityModel("set-utc-hours", "setUTCHours", "setUTCHours", minArgs = 1, maxArgs = 4)) + add(instanceModel("set-utc-milliseconds", "setUTCMilliseconds", consumedArgs = 1)) + add(instanceArityModel("set-utc-minutes", "setUTCMinutes", "setUTCMinutes", minArgs = 1, maxArgs = 3)) add(instanceArityModel("set-utc-month", "setUTCMonth", "setUTCMonth", minArgs = 1, maxArgs = 2)) add(instanceArityModel("set-utc-seconds", "setUTCSeconds", "setUTCSeconds", minArgs = 1, maxArgs = 2)) } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts index f288c708f..2bf54daec 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -261,6 +261,20 @@ export class DateModels { return DateModels.setHours(receiver, argumentCount, hours, minutes, seconds, milliseconds); } + static setUTCMilliseconds(receiver: DateValue, milliseconds: number): number { + return DateModels.setMilliseconds(receiver, milliseconds); + } + + static setUTCMinutes( + receiver: DateValue, + argumentCount: number, + minutes: number, + seconds: number, + milliseconds: number, + ): number { + return DateModels.setMinutes(receiver, argumentCount, minutes, seconds, milliseconds); + } + static setUTCMonth(receiver: DateValue, argumentCount: number, month: number, date: number): number { return DateModels.setMonth(receiver, argumentCount, month, date); } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index 6dff3b05a..71fc80ee9 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -75,6 +75,11 @@ class TsDateEtsIrModelTest { assertNumber(methodName = "anyAliasGetTime", expected = 456.0) } + @Test + fun `UTC minute and millisecond setters execute through source models`() { + assertNumber(methodName = "utcMinuteSetters", expected = 7_318_000.0) + } + @Test fun `concrete ISO formatting executes through the source model`() { val method = method("isoEpoch") diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index 95bd84ed0..bdf8c6057 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -210,7 +210,7 @@ class TsUnknownCallModelCatalogTest { assertTrue(catalog.modelIds.containsAll(expectedModelIds)) assertTrue("ts.date.constructor" in catalog.modelIds) assertTrue("ts.date.now" in catalog.modelIds) - assertEquals(expected = 36, actual = catalog.modelIds.count { it.startsWith("ts.date.") }) + assertEquals(expected = 38, actual = catalog.modelIds.count { it.startsWith("ts.date.") }) assertEquals(catalog.modelIds.distinct().sorted(), catalog.modelIds) assertSame(catalog, TsBuiltInUnknownCallModels.catalog()) assertFailsWith { (catalog.modelIds as MutableList).clear() } diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts index 48593b3cc..62ec46063 100644 --- a/usvm-ts/src/test/resources/models/DateEtsIr.ts +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -62,6 +62,13 @@ export class DateEtsIr { return date.getTime(); } + utcMinuteSetters(): number { + const date = new Date(0); + const minutesTimestamp = date.setUTCMinutes(61, -2, 1_001); + const millisecondsTimestamp = date.setUTCMilliseconds(-1); + return minutesTimestamp + millisecondsTimestamp; + } + setter(): number { const date = new Date(0); const timestamp = date.setUTCFullYear(2000, 1, 29); diff --git a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs index e6609c1de..0f11f6fd0 100644 --- a/usvm-ts/src/test/resources/models/DateModelsComparison.mjs +++ b/usvm-ts/src/test/resources/models/DateModelsComparison.mjs @@ -102,6 +102,8 @@ const setterCases = [ ["setMonth", [-13, 40], (date, args) => date.setUTCMonth(...args), (date, args) => DateModels.setMonth(date, args.length, ...args, 0)], ["setSeconds", [-61, 2_000], (date, args) => date.setUTCSeconds(...args), (date, args) => DateModels.setSeconds(date, args.length, ...args, 0)], ["setTime", [-1.9], (date, args) => date.setTime(...args), (date, args) => DateModels.setTime(date, ...args)], + ["setUTCMilliseconds", [-1], (date, args) => date.setUTCMilliseconds(...args), (date, args) => DateModels.setUTCMilliseconds(date, ...args)], + ["setUTCMinutes", [61, -2, 1_001], (date, args) => date.setUTCMinutes(...args), (date, args) => DateModels.setUTCMinutes(date, args.length, ...args, 0, 0)], ]; for (const timestamp of [-1, 0, 951_827_696_789]) { From 1cdd5f12b71dfcfe62aaddc6110b2639f61a9b28 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:21:37 +0300 Subject: [PATCH 21/33] Strengthen Date model registrations and tests --- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 141 +++++++++++------- .../usvm/machine/call/TsDateEtsIrModelTest.kt | 27 +++- .../src/test/resources/models/DateEtsIr.ts | 7 + 3 files changed, 118 insertions(+), 57 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index c0b3bfe60..ab972a3b0 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -56,24 +56,92 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { "toISOString", "valueOf", )) { - add(instanceModel(methodName, methodName, consumedArgs = 0)) + add(instanceModel(idSuffix = methodName, methodName = methodName, consumedArgs = 0)) } - add(instanceModel("set-date", "setDate", consumedArgs = 1)) - add(instanceArityModel("set-full-year", "setFullYear", "setFullYear", minArgs = 1, maxArgs = 3)) - add(instanceArityModel("set-hours", "setHours", "setHours", minArgs = 1, maxArgs = 4)) - add(instanceModel("set-milliseconds", "setMilliseconds", consumedArgs = 1)) - add(instanceArityModel("set-minutes", "setMinutes", "setMinutes", minArgs = 1, maxArgs = 3)) - add(instanceArityModel("set-month", "setMonth", "setMonth", minArgs = 1, maxArgs = 2)) - add(instanceArityModel("set-seconds", "setSeconds", "setSeconds", minArgs = 1, maxArgs = 2)) - add(instanceModel("set-time", "setTime", consumedArgs = 1)) - add(instanceModel("set-utc-date", "setUTCDate", consumedArgs = 1)) - add(instanceArityModel("set-utc-full-year", "setUTCFullYear", "setUTCFullYear", minArgs = 1, maxArgs = 3)) - add(instanceArityModel("set-utc-hours", "setUTCHours", "setUTCHours", minArgs = 1, maxArgs = 4)) - add(instanceModel("set-utc-milliseconds", "setUTCMilliseconds", consumedArgs = 1)) - add(instanceArityModel("set-utc-minutes", "setUTCMinutes", "setUTCMinutes", minArgs = 1, maxArgs = 3)) - add(instanceArityModel("set-utc-month", "setUTCMonth", "setUTCMonth", minArgs = 1, maxArgs = 2)) - add(instanceArityModel("set-utc-seconds", "setUTCSeconds", "setUTCSeconds", minArgs = 1, maxArgs = 2)) + add(instanceModel(idSuffix = "set-date", methodName = "setDate", consumedArgs = 1)) + add(instanceArityModel( + idSuffix = "set-full-year", + methodName = "setFullYear", + entryPointName = "setFullYear", + minArgs = 1, + maxArgs = 3, + )) + add(instanceArityModel( + idSuffix = "set-hours", + methodName = "setHours", + entryPointName = "setHours", + minArgs = 1, + maxArgs = 4, + )) + add(instanceModel( + idSuffix = "set-milliseconds", + methodName = "setMilliseconds", + consumedArgs = 1, + )) + add(instanceArityModel( + idSuffix = "set-minutes", + methodName = "setMinutes", + entryPointName = "setMinutes", + minArgs = 1, + maxArgs = 3, + )) + add(instanceArityModel( + idSuffix = "set-month", + methodName = "setMonth", + entryPointName = "setMonth", + minArgs = 1, + maxArgs = 2, + )) + add(instanceArityModel( + idSuffix = "set-seconds", + methodName = "setSeconds", + entryPointName = "setSeconds", + minArgs = 1, + maxArgs = 2, + )) + add(instanceModel(idSuffix = "set-time", methodName = "setTime", consumedArgs = 1)) + add(instanceModel(idSuffix = "set-utc-date", methodName = "setUTCDate", consumedArgs = 1)) + add(instanceArityModel( + idSuffix = "set-utc-full-year", + methodName = "setUTCFullYear", + entryPointName = "setUTCFullYear", + minArgs = 1, + maxArgs = 3, + )) + add(instanceArityModel( + idSuffix = "set-utc-hours", + methodName = "setUTCHours", + entryPointName = "setUTCHours", + minArgs = 1, + maxArgs = 4, + )) + add(instanceModel( + idSuffix = "set-utc-milliseconds", + methodName = "setUTCMilliseconds", + consumedArgs = 1, + )) + add(instanceArityModel( + idSuffix = "set-utc-minutes", + methodName = "setUTCMinutes", + entryPointName = "setUTCMinutes", + minArgs = 1, + maxArgs = 3, + )) + add(instanceArityModel( + idSuffix = "set-utc-month", + methodName = "setUTCMonth", + entryPointName = "setUTCMonth", + minArgs = 1, + maxArgs = 2, + )) + add(instanceArityModel( + idSuffix = "set-utc-seconds", + methodName = "setUTCSeconds", + entryPointName = "setUTCSeconds", + minArgs = 1, + maxArgs = 2, + )) } } @@ -174,21 +242,7 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { methodName = methodName, entryPointName = entryPointName, domainGuard = instanceGuard(minArgs = minArgs, numericArgs = maxArgs), - inputAdapter = arityAdapter(hasReceiver = true, maxArgs = maxArgs), - ) - - private fun staticArityModel( - idSuffix: String, - methodName: String, - entryPointName: String, - minArgs: Int, - maxArgs: Int, - ): TsUnknownCallModel = model( - idSuffix = idSuffix, - methodName = methodName, - entryPointName = entryPointName, - domainGuard = numericGuard(minArgs = minArgs, numericArgs = maxArgs), - inputAdapter = staticArityAdapter(maxArgs = maxArgs), + inputAdapter = arityAdapter(maxArgs = maxArgs), ) private fun model( @@ -240,35 +294,16 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } - private fun arityAdapter( - hasReceiver: Boolean, - maxArgs: Int, - ) = TsEtsIrUnknownCallModelInputAdapter { state, call -> - with(state.ctx) { - val receiver = if (hasReceiver) { - listOf(call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null) - } else { - emptyList() - } - val arguments = call.resolvedArguments(maxArgs)?.toMutableList() - ?: return@TsEtsIrUnknownCallModelInputAdapter null - while (arguments.size < maxArgs) { - arguments += mkFp64(0.0) - } - - receiver + mkFp64(call.arguments.size.toDouble()) + arguments - } - } - - private fun staticArityAdapter(maxArgs: Int) = TsEtsIrUnknownCallModelInputAdapter { state, call -> + private fun arityAdapter(maxArgs: Int) = TsEtsIrUnknownCallModelInputAdapter { state, call -> with(state.ctx) { + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null val arguments = call.resolvedArguments(maxArgs)?.toMutableList() ?: return@TsEtsIrUnknownCallModelInputAdapter null while (arguments.size < maxArgs) { arguments += mkFp64(0.0) } - listOf(mkFp64(call.arguments.size.toDouble())) + arguments + listOf(receiver, mkFp64(call.arguments.size.toDouble())) + arguments } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index 71fc80ee9..c4f49f1ef 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -99,12 +99,29 @@ class TsDateEtsIrModelTest { val states = analyze(method) assertTrue(states.isNotEmpty()) - val allResultsAreNumbers = states.all { state -> - val result = assertIs(TsTestResolver().resolve(method, state).returnValue) - result.number.isFinite() || result.number.isNaN() + val tests = states.map { state -> TsTestResolver().resolve(method, state) } + tests.forEach { test -> + val timestamp = assertIs(test.before.parameters.single()).number + val actual = assertIs(test.returnValue).number + val expected = if (!timestamp.isFinite() || timestamp < -MAX_DATE_TIME || timestamp > MAX_DATE_TIME) { + Double.NaN + } else { + timestamp.toLong().toDouble() + } + + if (expected.isNaN()) { + assertTrue(actual.isNaN(), "TimeClip($timestamp) must be NaN, got $actual") + } else { + assertEquals(expected, actual, "TimeClip($timestamp)") + } } - assertTrue(allResultsAreNumbers) + assertTrue(tests.any { test -> + assertIs(test.returnValue).number.isNaN() + }) + assertTrue(tests.any { test -> + assertIs(test.returnValue).number.isFinite() + }) } private fun assertNumber(methodName: String, expected: Double) { @@ -136,6 +153,8 @@ class TsDateEtsIrModelTest { .single { it.name == name } private companion object { + const val MAX_DATE_TIME = 8_640_000_000_000_000.0 + val machineOptions = UMachineOptions( pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), stateCollectionStrategy = StateCollectionStrategy.ALL, diff --git a/usvm-ts/src/test/resources/models/DateEtsIr.ts b/usvm-ts/src/test/resources/models/DateEtsIr.ts index 62ec46063..194705cfa 100644 --- a/usvm-ts/src/test/resources/models/DateEtsIr.ts +++ b/usvm-ts/src/test/resources/models/DateEtsIr.ts @@ -76,6 +76,13 @@ export class DateEtsIr { } symbolicRoundTrip(timestamp: number): number { + if (timestamp === 123.9) { + return new Date(timestamp).valueOf(); + } + if (timestamp === -123.9) { + return new Date(timestamp).valueOf(); + } + return new Date(timestamp).valueOf(); } } From cf2d6d8d2a55086047a83a8eb00186d988049c9f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:23:16 +0300 Subject: [PATCH 22/33] Fix Date registration formatting --- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 184 ++++++++++-------- 1 file changed, 104 insertions(+), 80 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index ab972a3b0..c8e22129e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -60,88 +60,112 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } add(instanceModel(idSuffix = "set-date", methodName = "setDate", consumedArgs = 1)) - add(instanceArityModel( - idSuffix = "set-full-year", - methodName = "setFullYear", - entryPointName = "setFullYear", - minArgs = 1, - maxArgs = 3, - )) - add(instanceArityModel( - idSuffix = "set-hours", - methodName = "setHours", - entryPointName = "setHours", - minArgs = 1, - maxArgs = 4, - )) - add(instanceModel( - idSuffix = "set-milliseconds", - methodName = "setMilliseconds", - consumedArgs = 1, - )) - add(instanceArityModel( - idSuffix = "set-minutes", - methodName = "setMinutes", - entryPointName = "setMinutes", - minArgs = 1, - maxArgs = 3, - )) - add(instanceArityModel( - idSuffix = "set-month", - methodName = "setMonth", - entryPointName = "setMonth", - minArgs = 1, - maxArgs = 2, - )) - add(instanceArityModel( - idSuffix = "set-seconds", - methodName = "setSeconds", - entryPointName = "setSeconds", - minArgs = 1, - maxArgs = 2, - )) + add( + instanceArityModel( + idSuffix = "set-full-year", + methodName = "setFullYear", + entryPointName = "setFullYear", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-hours", + methodName = "setHours", + entryPointName = "setHours", + minArgs = 1, + maxArgs = 4, + ) + ) + add( + instanceModel( + idSuffix = "set-milliseconds", + methodName = "setMilliseconds", + consumedArgs = 1, + ) + ) + add( + instanceArityModel( + idSuffix = "set-minutes", + methodName = "setMinutes", + entryPointName = "setMinutes", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-month", + methodName = "setMonth", + entryPointName = "setMonth", + minArgs = 1, + maxArgs = 2, + ) + ) + add( + instanceArityModel( + idSuffix = "set-seconds", + methodName = "setSeconds", + entryPointName = "setSeconds", + minArgs = 1, + maxArgs = 2, + ) + ) add(instanceModel(idSuffix = "set-time", methodName = "setTime", consumedArgs = 1)) add(instanceModel(idSuffix = "set-utc-date", methodName = "setUTCDate", consumedArgs = 1)) - add(instanceArityModel( - idSuffix = "set-utc-full-year", - methodName = "setUTCFullYear", - entryPointName = "setUTCFullYear", - minArgs = 1, - maxArgs = 3, - )) - add(instanceArityModel( - idSuffix = "set-utc-hours", - methodName = "setUTCHours", - entryPointName = "setUTCHours", - minArgs = 1, - maxArgs = 4, - )) - add(instanceModel( - idSuffix = "set-utc-milliseconds", - methodName = "setUTCMilliseconds", - consumedArgs = 1, - )) - add(instanceArityModel( - idSuffix = "set-utc-minutes", - methodName = "setUTCMinutes", - entryPointName = "setUTCMinutes", - minArgs = 1, - maxArgs = 3, - )) - add(instanceArityModel( - idSuffix = "set-utc-month", - methodName = "setUTCMonth", - entryPointName = "setUTCMonth", - minArgs = 1, - maxArgs = 2, - )) - add(instanceArityModel( - idSuffix = "set-utc-seconds", - methodName = "setUTCSeconds", - entryPointName = "setUTCSeconds", - minArgs = 1, - maxArgs = 2, - )) + add( + instanceArityModel( + idSuffix = "set-utc-full-year", + methodName = "setUTCFullYear", + entryPointName = "setUTCFullYear", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-hours", + methodName = "setUTCHours", + entryPointName = "setUTCHours", + minArgs = 1, + maxArgs = 4, + ) + ) + add( + instanceModel( + idSuffix = "set-utc-milliseconds", + methodName = "setUTCMilliseconds", + consumedArgs = 1, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-minutes", + methodName = "setUTCMinutes", + entryPointName = "setUTCMinutes", + minArgs = 1, + maxArgs = 3, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-month", + methodName = "setUTCMonth", + entryPointName = "setUTCMonth", + minArgs = 1, + maxArgs = 2, + ) + ) + add( + instanceArityModel( + idSuffix = "set-utc-seconds", + methodName = "setUTCSeconds", + entryPointName = "setUTCSeconds", + minArgs = 1, + maxArgs = 2, + ) + ) } } From 6f32a79bf90ed4ddeda722f41c8ed8e2d6cfadf6 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:25:37 +0300 Subject: [PATCH 23/33] Use Math.floor in Date calendar arithmetic --- .../resources/org/usvm/machine/call/models/DateModels.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts index 2bf54daec..701bb5201 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/DateModels.ts @@ -492,15 +492,11 @@ export class DateModels { return value; } - return value < 0 - ? -DateModels.floorDiv(-value, 1) - : DateModels.floorDiv(value, 1); + return value < 0 ? -Math.floor(-value) : Math.floor(value); } private static floorDiv(dividend: number, divisor: number): number { - const remainder = dividend % divisor; - const quotient = (dividend - remainder) / divisor; - return remainder < 0 ? quotient - 1 : quotient; + return Math.floor(dividend / divisor); } private static mod(dividend: number, divisor: number): number { From 194ea90944fa07357f0d0ddea3ab019be5c27ff5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:26:30 +0300 Subject: [PATCH 24/33] Document model coverage and declare Date rounding dependency --- usvm-ts/UNKNOWN_CALL_MODELS.md | 36 ++++++++++++++----- .../call/intrinsic/TsDateEtsIrModelFamily.kt | 1 + 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 005d14b55..22426ec63 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -48,7 +48,7 @@ This is the only model-selection setting. | --- | --- | | `TsUnknownCallModelSelection.All` | Enable every built-in model. This is the default. | | `TsUnknownCallModelSelection.Only(emptySet())` | Disable every built-in model. | -| `TsUnknownCallModelSelection.Only(setOf("id", ...))` | Enable exactly the listed built-in model IDs. | +| `TsUnknownCallModelSelection.Only(setOf("id", ...))` | Enable the listed built-in model IDs and their declared dependencies. | Unknown IDs are rejected when the machine creates its immutable per-run catalog. The selected models are captured at that point, so later mutations of the selection set cannot change an active run. @@ -56,16 +56,33 @@ point, so later mutations of the selection set cannot change an active run. Use the model's `id`, for example `ts.array.pop`. A target method name, class name, source filename, or artifact hash is not a model ID. -Built-ins are `object` implementations of the sealed `TsBuiltInUnknownCallModel` interface in the -`org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; adding a -model requires no manual registry entry. Discovery and the default catalog are computed once. +Built-ins are singleton models or families implementing the sealed `TsBuiltInUnknownCallModel` interface in the +`org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; a family +supplies its parameterized models without a separate registry. Discovery and the default catalog are computed once. -The built-in catalog currently contains: +The built-in catalog contains 62 public API names, plus three internal String storage primitives: | ID | Implementation | Accepted calls | | --- | --- | --- | | `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | | `ts.array.pop` | TypeScript/EtsIR body | Zero-argument `pop` on a definitely one-dimensional array that also satisfies the symbolic runtime type guard. | +| `ts.array.includes`, `ts.array.indexOf`, `ts.array.lastIndexOf` | TypeScript/EtsIR bodies | One-dimensional arrays and numeric positions, with the missing-slot exclusions below. | +| `ts.string.charAt`, `ts.string.charCodeAt`, `ts.string.includes`, `ts.string.indexOf`, `ts.string.lastIndexOf`, `ts.string.startsWith`, `ts.string.endsWith` | TypeScript/EtsIR bodies | Initialized concrete strings; numeric positions. `charAt` requires a concrete position. | +| `ts.math.abs`, `ts.math.ceil`, `ts.math.floor`, `ts.math.max`, `ts.math.min`, `ts.math.round`, `ts.math.sqrt`, `ts.math.trunc` | Kotlin FP primitives | Numeric arguments; dynamic coercions use fallback. | +| `ts.number.isFinite`, `ts.number.isInteger`, `ts.number.isNaN`, `ts.number.isSafeInteger` | Kotlin FP/type primitives | Non-coercing Number predicates, including runtime-kind guards. | +| `ts.date.*` (38 IDs) | TypeScript/EtsIR bodies | Numeric Date construction, `UTC`, fixed-clock `now`, getters, setters, `valueOf`, and source `toISOString`; see the Date boundary below. | + +Matching standard calls are assumed to refer to genuine builtins. Monkey patching and prototype replacement are +outside this experiment; no runtime provenance protocol is imposed. Receiver and argument checks establish the +memory representation and supported input domain. + +The September 2026 corpus census contains 22,769 call/constructor sites. Its 49-API shortlist accounts for 1,026 +sites: Number/Math (275), new Array/String searches (130), existing pop/shift (52), and Date (569). Every shortlisted +API name has a catalog entry. Eleven adjacent APIs add 280 census sites; `setUTCMinutes` and `setUTCMilliseconds` +complete the numeric UTC setter family but have no sites in this census. These 1,306 associated sites are +an inventory count, **not executed or replay-confirmed coverage**: imports, input representation, fallback domains, +and other unsupported operations can still prevent execution. The wider research inventory contains 271 +unambiguous standard/host API names over 7,720 sites; most are not implemented by this catalog. The common instance-call pipeline splits fake-value wrappers and conditional references under their runtime-kind and branch guards before selecting an approximation or resolving a method. A wrapped array can therefore use the @@ -291,18 +308,19 @@ In contrast, `Array.pop` is expressed as the TypeScript body shown above. The built-in Date family keeps Gregorian calendar arithmetic, component overflow, leap years, and TimeClip in `DateModels.ts`. Kotlin only routes calls, injects the experiment clock, and exposes the model's numeric timestamp -slot on a Date receiver. +slot on a Date receiver. Calendar division and truncation use the declared `ts.math.floor` dependency. The current experiment has these explicit limits: -- local getters, setters, and numeric component constructors use UTC, so `getTimezoneOffset()` returns zero and DST - behavior is outside the model domain; +- local getters, setters, and numeric component constructors use UTC; `getTimezoneOffset()` returns zero for valid + dates and NaN for invalid dates. DST behavior is outside the model domain; - `Date.now()` and `new Date()` require `TsOptions.dateNowMilliseconds`; one fixed value is reused throughout the analysis, and both calls use fallback when it is absent; - one-argument construction supports numeric timestamps only; string parsing and copying another Date are outside the model domain; - symbolic string formatting is not claimed: `toISOString()` is a source implementation for supported concrete - execution, while symbolic string conversion remains subject to the engine's string limitations. + execution, while symbolic string conversion remains subject to the engine's string limitations. Invalid ISO + formatting reaches the unsupported nested `RangeError` constructor and the configured fallback. Good intrinsic candidates include: diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt index c8e22129e..77f536ab6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsDateEtsIrModelFamily.kt @@ -284,6 +284,7 @@ internal object TsDateEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { artifact = artifact.withEntryPoint(entryPointName), domainGuard = domainGuard, inputAdapter = inputAdapter, + requiredModelIds = setOf("ts.math.floor"), ) private fun instanceGuard( From 1a6315d6ae1d6182c202c562b20210f99ccfd879 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:27:11 +0300 Subject: [PATCH 25/33] Fix Date test formatting --- .../usvm/machine/call/TsDateEtsIrModelTest.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt index c4f49f1ef..c08e58548 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsDateEtsIrModelTest.kt @@ -116,12 +116,16 @@ class TsDateEtsIrModelTest { } } - assertTrue(tests.any { test -> - assertIs(test.returnValue).number.isNaN() - }) - assertTrue(tests.any { test -> - assertIs(test.returnValue).number.isFinite() - }) + assertTrue( + tests.any { test -> + assertIs(test.returnValue).number.isNaN() + } + ) + assertTrue( + tests.any { test -> + assertIs(test.returnValue).number.isFinite() + } + ) } private fun assertNumber(methodName: String, expected: Double) { From 2e80186f8fa4e1bc85c8193f122e779c1739e137 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:30:35 +0300 Subject: [PATCH 26/33] Allow modeled NaN checks in Array removal matrix --- .../org/usvm/machine/call/TsArrayShiftMatrixTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt index 312b88dbd..51c72deea 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftMatrixTest.kt @@ -66,9 +66,15 @@ class TsArrayShiftMatrixTest { } val actual = values.map { assertIs(it).number } + val removalDecision = TsUnknownCallDecision.ModelApplied(modelId = "ts.array.$methodName") + val allowedDecisions = setOf( + removalDecision, + TsUnknownCallDecision.ModelApplied(modelId = "ts.number.isNaN"), + ) + assertEquals(listOf(expected[index].toDouble()), actual) - assertEquals(case.shiftCount, events.size) - assertTrue(events.all { it.decision == TsUnknownCallDecision.ModelApplied("ts.array.$methodName") }) + assertEquals(case.shiftCount, events.count { it.decision == removalDecision }) + assertTrue(events.all { it.decision in allowedDecisions }) } } } From b279fbedbf466e681e2166c644e4196656e708d2 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:38:03 +0300 Subject: [PATCH 27/33] Satisfy numeric model style checks --- ...ls.kt => TsNumericIntrinsicModelFamily.kt} | 293 +++++++++--------- 1 file changed, 154 insertions(+), 139 deletions(-) rename usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/{TsNumericIntrinsicModels.kt => TsNumericIntrinsicModelFamily.kt} (50%) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt similarity index 50% rename from usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt rename to usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt index 23f853103..13b661479 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModels.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt @@ -29,104 +29,120 @@ internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily const val NUMBER_IS_NAN_ID: String = "ts.number.isNaN" const val NUMBER_IS_SAFE_INTEGER_ID: String = "ts.number.isSafeInteger" - override val models: List = listOf( - NumericIntrinsicModel( - id = MATH_ABS_ID, - methodName = "abs", - implementation = { state, call -> - unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } - }, - ), - NumericIntrinsicModel( - id = MATH_CEIL_ID, - methodName = "ceil", - implementation = { state, call -> - unaryMathCall(state, call) { value -> - with(state.ctx) { - mkFpRoundToIntegralExpr( - roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), - value = value, - ) - } - } - }, - ), - NumericIntrinsicModel( - id = MATH_FLOOR_ID, - methodName = "floor", - implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardNegative), - ), - NumericIntrinsicModel( - id = MATH_MAX_ID, - methodName = "max", - implementation = { state, call -> - variadicMathCall( - state = state, - call = call, - identity = Double.NEGATIVE_INFINITY, - combine = state::mathMax, - ) - }, - ), - NumericIntrinsicModel( - id = MATH_MIN_ID, - methodName = "min", - implementation = { state, call -> - variadicMathCall( - state = state, - call = call, - identity = Double.POSITIVE_INFINITY, - combine = state::mathMin, - ) - }, - ), - NumericIntrinsicModel( - id = MATH_ROUND_ID, - methodName = "round", - implementation = { state, call -> unaryMathCall(state, call, state::mathRound) }, - ), - NumericIntrinsicModel( - id = MATH_SQRT_ID, - methodName = "sqrt", - implementation = { state, call -> - unaryMathCall(state, call) { value -> - state.ctx.mkFpSqrtExpr(state.ctx.fpRoundingModeSortDefaultValue(), value) + private val mathAbsModel = NumericIntrinsicModel( + id = MATH_ABS_ID, + methodName = "abs", + implementation = { state, call -> + unaryMathCall(state, call) { value -> state.ctx.mkFpAbsExpr(value) } + }, + ) + private val mathCeilModel = NumericIntrinsicModel( + id = MATH_CEIL_ID, + methodName = "ceil", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + with(state.ctx) { + mkFpRoundToIntegralExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardPositive), + value = value, + ) } - }, - ), - NumericIntrinsicModel( - id = MATH_TRUNC_ID, - methodName = "trunc", - implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardZero), - ), - NumericIntrinsicModel( - id = NUMBER_IS_FINITE_ID, - methodName = "isFinite", - implementation = { state, call -> - numberPredicate(state, call) { value -> - with(state.ctx) { - mkAnd(mkFpIsNaNExpr(value).not(), mkFpIsInfiniteExpr(value).not()) - } + } + }, + ) + private val mathFloorModel = NumericIntrinsicModel( + id = MATH_FLOOR_ID, + methodName = "floor", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardNegative), + ) + private val mathMaxModel = NumericIntrinsicModel( + id = MATH_MAX_ID, + methodName = "max", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.NEGATIVE_INFINITY, + combine = state::mathMax, + ) + }, + ) + private val mathMinModel = NumericIntrinsicModel( + id = MATH_MIN_ID, + methodName = "min", + implementation = { state, call -> + variadicMathCall( + state = state, + call = call, + identity = Double.POSITIVE_INFINITY, + combine = state::mathMin, + ) + }, + ) + private val mathRoundModel = NumericIntrinsicModel( + id = MATH_ROUND_ID, + methodName = "round", + implementation = { state, call -> unaryMathCall(state, call, state::mathRound) }, + ) + private val mathSqrtModel = NumericIntrinsicModel( + id = MATH_SQRT_ID, + methodName = "sqrt", + implementation = { state, call -> + unaryMathCall(state, call) { value -> + state.ctx.mkFpSqrtExpr(state.ctx.fpRoundingModeSortDefaultValue(), value) + } + }, + ) + private val mathTruncModel = NumericIntrinsicModel( + id = MATH_TRUNC_ID, + methodName = "trunc", + implementation = roundingMathCall(roundingMode = KFpRoundingMode.RoundTowardZero), + ) + private val numberIsFiniteModel = NumericIntrinsicModel( + id = NUMBER_IS_FINITE_ID, + methodName = "isFinite", + implementation = { state, call -> + numberPredicate(state, call) { value -> + with(state.ctx) { + val isNotNaN = mkFpIsNaNExpr(value).not() + val isNotInfinite = mkFpIsInfiniteExpr(value).not() + + mkAnd(isNotNaN, isNotInfinite) } - }, - ), - NumericIntrinsicModel( - id = NUMBER_IS_INTEGER_ID, - methodName = "isInteger", - implementation = { state, call -> numberPredicate(state, call, state::isInteger) }, - ), - NumericIntrinsicModel( - id = NUMBER_IS_NAN_ID, - methodName = "isNaN", - implementation = { state, call -> - numberPredicate(state, call) { value -> state.ctx.mkFpIsNaNExpr(value) } - }, - ), - NumericIntrinsicModel( - id = NUMBER_IS_SAFE_INTEGER_ID, - methodName = "isSafeInteger", - implementation = { state, call -> numberPredicate(state, call, state::isSafeInteger) }, - ), + } + }, + ) + private val numberIsIntegerModel = NumericIntrinsicModel( + id = NUMBER_IS_INTEGER_ID, + methodName = "isInteger", + implementation = { state, call -> numberPredicate(state, call, state::isInteger) }, + ) + private val numberIsNaNModel = NumericIntrinsicModel( + id = NUMBER_IS_NAN_ID, + methodName = "isNaN", + implementation = { state, call -> + numberPredicate(state, call) { value -> state.ctx.mkFpIsNaNExpr(value) } + }, + ) + private val numberIsSafeIntegerModel = NumericIntrinsicModel( + id = NUMBER_IS_SAFE_INTEGER_ID, + methodName = "isSafeInteger", + implementation = { state, call -> numberPredicate(state, call, state::isSafeInteger) }, + ) + + override val models: List = listOf( + mathAbsModel, + mathCeilModel, + mathFloorModel, + mathMaxModel, + mathMinModel, + mathRoundModel, + mathSqrtModel, + mathTruncModel, + numberIsFiniteModel, + numberIsIntegerModel, + numberIsNaNModel, + numberIsSafeIntegerModel, ) } @@ -251,9 +267,13 @@ private fun TsState.isInteger(value: UExpr) = with(ctx) { } private fun TsState.isSafeInteger(value: UExpr) = with(ctx) { + val absoluteValue = mkFpAbsExpr(value) + val maxSafeInteger = mkFp(MAX_SAFE_INTEGER, fp64Sort) + val isInSafeRange = mkFpLessOrEqualExpr(absoluteValue, maxSafeInteger) + mkAnd( isInteger(value), - mkFpLessOrEqualExpr(mkFpAbsExpr(value), mkFp(MAX_SAFE_INTEGER, fp64Sort)), + isInSafeRange, ) } @@ -262,24 +282,20 @@ private fun TsState.mathMin( right: UExpr, ): UExpr = with(ctx) { val zero = mkFp(0.0, fp64Sort) - val negativeZero = mkFp(-0.0, fp64Sort) - val eitherNegative = mkOr(mkFpIsNegativeExpr(left), mkFpIsNegativeExpr(right)) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val leftIsNegative = mkFpIsNegativeExpr(left) + val rightIsNegative = mkFpIsNegativeExpr(right) + val eitherNegative = mkOr(leftIsNegative, rightIsNegative) val signedZero = mkIte(eitherNegative, negativeZero, zero) - val bothZero = mkAnd(mkFpIsZeroExpr(left), mkFpIsZeroExpr(right)) + val leftIsZero = mkFpIsZeroExpr(left) + val rightIsZero = mkFpIsZeroExpr(right) + val bothZero = mkAnd(leftIsZero, rightIsZero) + val equalResult = mkIte(bothZero, signedZero, left) + val rightLessResult = mkIte(mkFpLessExpr(right, left), right, equalResult) + val leftLessResult = mkIte(mkFpLessExpr(left, right), left, rightLessResult) + val rightNaNResult = mkIte(mkFpIsNaNExpr(right), right, leftLessResult) - mkIte( - mkFpIsNaNExpr(left), - left, - mkIte( - mkFpIsNaNExpr(right), - right, - mkIte( - mkFpLessExpr(left, right), - left, - mkIte(mkFpLessExpr(right, left), right, mkIte(bothZero, signedZero, left)), - ), - ), - ) + mkIte(mkFpIsNaNExpr(left), left, rightNaNResult) } private fun TsState.mathMax( @@ -287,24 +303,20 @@ private fun TsState.mathMax( right: UExpr, ): UExpr = with(ctx) { val zero = mkFp(0.0, fp64Sort) - val negativeZero = mkFp(-0.0, fp64Sort) - val eitherPositive = mkOr(mkFpIsPositiveExpr(left), mkFpIsPositiveExpr(right)) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val leftIsPositive = mkFpIsPositiveExpr(left) + val rightIsPositive = mkFpIsPositiveExpr(right) + val eitherPositive = mkOr(leftIsPositive, rightIsPositive) val signedZero = mkIte(eitherPositive, zero, negativeZero) - val bothZero = mkAnd(mkFpIsZeroExpr(left), mkFpIsZeroExpr(right)) + val leftIsZero = mkFpIsZeroExpr(left) + val rightIsZero = mkFpIsZeroExpr(right) + val bothZero = mkAnd(leftIsZero, rightIsZero) + val equalResult = mkIte(bothZero, signedZero, left) + val rightGreaterResult = mkIte(mkFpGreaterExpr(right, left), right, equalResult) + val leftGreaterResult = mkIte(mkFpGreaterExpr(left, right), left, rightGreaterResult) + val rightNaNResult = mkIte(mkFpIsNaNExpr(right), right, leftGreaterResult) - mkIte( - mkFpIsNaNExpr(left), - left, - mkIte( - mkFpIsNaNExpr(right), - right, - mkIte( - mkFpGreaterExpr(left, right), - left, - mkIte(mkFpGreaterExpr(right, left), right, mkIte(bothZero, signedZero, left)), - ), - ), - ) + mkIte(mkFpIsNaNExpr(left), left, rightNaNResult) } private fun TsState.mathRound(value: UExpr): UExpr = with(ctx) { @@ -314,16 +326,16 @@ private fun TsState.mathRound(value: UExpr): UExpr = with( value = value, ) val fraction = mkFpSubExpr(roundingMode, value, floor) - val rounded = mkIte( - mkFpLessExpr(fraction, mkFp(0.5, fp64Sort)), - floor, - mkFpAddExpr(roundingMode, floor, mkFp(1.0, fp64Sort)), - ) - val signedRounded = mkIte( - mkAnd(mkFpIsNegativeExpr(value), mkFpIsZeroExpr(rounded)), - mkFp(-0.0, fp64Sort), - rounded, - ) + val half = mkFp(ROUNDING_THRESHOLD, fp64Sort) + val useFloor = mkFpLessExpr(fraction, half) + val increment = mkFp(ROUNDING_INCREMENT, fp64Sort) + val incrementedFloor = mkFpAddExpr(roundingMode, floor, increment) + val rounded = mkIte(useFloor, floor, incrementedFloor) + val isNegative = mkFpIsNegativeExpr(value) + val roundedIsZero = mkFpIsZeroExpr(rounded) + val returnsNegativeZero = mkAnd(isNegative, roundedIsZero) + val negativeZero = mkFp(NEGATIVE_ZERO, fp64Sort) + val signedRounded = mkIte(returnsNegativeZero, negativeZero, rounded) val preserveInput = mkOr( mkFpIsNaNExpr(value), mkFpIsInfiniteExpr(value), @@ -334,3 +346,6 @@ private fun TsState.mathRound(value: UExpr): UExpr = with( } private const val MAX_SAFE_INTEGER: Double = 9_007_199_254_740_991.0 +private const val NEGATIVE_ZERO: Double = -0.0 +private const val ROUNDING_THRESHOLD: Double = 0.5 +private const val ROUNDING_INCREMENT: Double = 1.0 From 8e1353c948fe6ec4380cb627f28259bae9a9b3d7 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 18:38:22 +0300 Subject: [PATCH 28/33] Clean up sequence model style --- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 51 ++++++++++------ .../intrinsic/TsStringEtsIrModelFamily.kt | 59 +++++++++++-------- 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index 1fdf527c5..825fbe817 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -41,21 +41,30 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { val searchRef = searchElement ?.takeIf { it.sort == addressSort } ?.asExpr(addressSort) - if (array.hasFakeValueBranch() || receiverType?.dimensions != 1 || searchRef?.hasFakeValueBranch() == true) { + if ( + array.hasFakeValueBranch() || receiverType?.dimensions != 1 || + searchRef?.hasFakeValueBranch() == true + ) { falseExpr } else { val elementSort = typeToSort(receiverType.elementType) val excludesMissingSlot = when { searchElement == mkUndefinedValue() && - (call.callee.name in setOf("indexOf", "lastIndexOf") || - elementSort == fp64Sort || elementSort == boolSort) -> falseExpr + ( + call.callee.name in setOf("indexOf", "lastIndexOf") || + elementSort == fp64Sort || elementSort == boolSort + ) -> falseExpr - elementSort == fp64Sort && searchElement?.sort == fp64Sort -> mkNot( - mkFpEqualExpr(searchElement.asExpr(fp64Sort), mkFp64(0.0)) - ) + elementSort == fp64Sort && searchElement?.sort == fp64Sort -> { + val searchNumber = searchElement.asExpr(fp64Sort) + val zero = mkFp64(0.0) - elementSort == boolSort && searchElement?.sort == boolSort -> + mkNot(mkFpEqualExpr(searchNumber, zero)) + } + + elementSort == boolSort && searchElement?.sort == boolSort -> { searchElement.asExpr(boolSort) + } else -> trueExpr } @@ -73,8 +82,9 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { call.resolvedInstanceInputs()?.let { inputs -> when { call.arguments.size == 1 -> inputs + state.ctx.mkFp64(0.0) - call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> { inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs else -> null @@ -86,8 +96,9 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { call.resolvedInstanceInputs()?.let { inputs -> when { call.arguments.size == 1 -> inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) - call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> + call.arguments.size == 2 && inputs.last() == state.ctx.mkUndefinedValue() -> { inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } call.arguments.size == 2 && inputs.last().sort == state.ctx.fp64Sort -> inputs else -> null @@ -123,18 +134,22 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { id: String, methodName: String, inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, - ): TsUnknownCallModel = TsEtsIrUnknownCallModel( - id = id, - target = TsUnknownCallTarget( + ): TsUnknownCallModel { + val target = TsUnknownCallTarget( methodName = methodName, enclosingClassName = "Array".takeUnless { methodName == "pop" }, failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, - ), - artifact = artifact(methodName), - domainGuard = arrayDomain, - inputAdapter = inputAdapter, - requiredModelIds = setOf(MATH_FLOOR_MODEL_ID).takeUnless { methodName == "pop" }.orEmpty(), - ) + ) + + return TsEtsIrUnknownCallModel( + id = id, + target = target, + artifact = artifact(methodName), + domainGuard = arrayDomain, + inputAdapter = inputAdapter, + requiredModelIds = setOf(MATH_FLOOR_MODEL_ID).takeUnless { methodName == "pop" }.orEmpty(), + ) + } private fun artifact(methodName: String): TsEtsIrUnknownCallModelArtifact { val artifact = baseArtifact diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 8affd26e5..b5e3643ca 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -48,8 +48,9 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { call.resolvedInstanceInputs()?.let { inputs -> when { call.arguments.isEmpty() -> inputs + state.ctx.mkFp64(0.0) - call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> + call.arguments.size == 1 && inputs.last() == state.ctx.mkUndefinedValue() -> { inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } call.arguments.size == 1 && inputs.last().sort == state.ctx.fp64Sort -> inputs else -> null @@ -60,12 +61,14 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val optionalPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> call.resolvedInstanceInputs()?.let { inputs -> when { - call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> { inputs + state.ctx.mkFp64(0.0) + } call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && - inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.last() == state.ctx.mkUndefinedValue() -> { inputs.dropLast(1) + state.ctx.mkFp64(0.0) + } call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && inputs.last().sort == state.ctx.fp64Sort -> inputs @@ -78,12 +81,14 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val optionalEndPositionAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> call.resolvedInstanceInputs()?.let { inputs -> when { - call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> + call.arguments.size == 1 && inputs.last().sort == state.ctx.addressSort -> { inputs + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + } call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && - inputs.last() == state.ctx.mkUndefinedValue() -> + inputs.last() == state.ctx.mkUndefinedValue() -> { inputs.dropLast(1) + state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + } call.arguments.size == 2 && inputs[1].sort == state.ctx.addressSort && inputs.last().sort == state.ctx.fp64Sort -> inputs @@ -109,10 +114,11 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { with(state.ctx) { val receiver = inputs.getOrNull(0) val searchString = inputs.getOrNull(1) - if ( - receiver !is UConcreteHeapRef || searchString !is UConcreteHeapRef || - getStringConstantValue(receiver) == null || getStringConstantValue(searchString) == null - ) { + val receiverIsConstant = receiver is UConcreteHeapRef && getStringConstantValue(receiver) != null + val searchStringIsConstant = + searchString is UConcreteHeapRef && getStringConstantValue(searchString) != null + + if (!receiverIsConstant || !searchStringIsConstant) { falseExpr } else { mkAnd( @@ -309,16 +315,15 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { inputs: List>, ): TsUnknownCallModelExecution? = with(state.ctx) { val code = inputs.singleOrNull() as? KFp64Value ?: return null + val successor = TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { + mkInitializedStringConstant(code.value.toInt().toChar().toString()) + }, + ) TsUnknownCallModelExecution( - successors = listOf( - TsUnknownCallModelSuccessor( - guard = trueExpr, - completion = TsUnknownCallModelCompletion.Normal { - mkInitializedStringConstant(code.value.toInt().toChar().toString()) - }, - ) - ), + successors = listOf(successor), ) } @@ -326,15 +331,17 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { state: TsState, guard: org.usvm.UBoolExpr, result: UExpr<*>, - ): TsUnknownCallModelExecution = TsUnknownCallModelExecution( - successors = listOf( - TsUnknownCallModelSuccessor( - guard = guard, - completion = TsUnknownCallModelCompletion.Normal { result }, - ) - ), - residualGuard = guard.takeUnless { it == state.ctx.trueExpr }?.let(state.ctx::mkNot), - ) + ): TsUnknownCallModelExecution { + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + + return TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == state.ctx.trueExpr }?.let(state.ctx::mkNot), + ) + } private fun TsUnknownCall.resolvedInstanceInputs(): List>? { val resolvedReceiver = receiver?.resolved ?: return null From c08c8c6e4174a1b6865737b1d366302f402530f1 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 20:38:30 +0300 Subject: [PATCH 29/33] Support symbolic Calls inputs and preflight telemetry --- .../org/usvm/ts/calls/CallsExperiment.kt | 37 +- .../org/usvm/ts/calls/CallsSourceReplay.kt | 112 +++- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 320 +++++++++++ .../ts/calls/CallsUnknownCallTelemetry.kt | 154 ++++++ .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 502 ++++++++++++++---- .../org/usvm/ts/calls/CallsExperimentTest.kt | 82 +++ .../usvm/ts/calls/CallsSourceReplayTest.kt | 99 +++- .../ts/calls/CallsUnknownCallTelemetryTest.kt | 85 +++ .../calls/CurrentTsCallsSymbolicEngineTest.kt | 415 +++++++++++++++ .../calls/SourceTargetReplayFixture.ts | 24 + .../src/source-inspector-cli.ts | 50 ++ .../usvm/ts/pbt/fastcheck/FastCheckRuntime.kt | 3 + .../fastcheck/TypeScriptSourceInspector.kt | 40 ++ .../ts/pbt/mapping/EtsEntryPointResolver.kt | 45 +- .../usvm/ts/pbt/mapping/EtsMappingModel.kt | 7 + .../PropertyEtsExportResolutionTest.kt | 57 ++ .../mapping/exports/CapturedBuiltinArrow.ts | 19 + .../main/kotlin/org/usvm/machine/TsMachine.kt | 12 +- .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 10 +- .../intrinsic/TsArrayIsArrayIntrinsicModel.kt | 91 ++++ .../intrinsic/TsStringEtsIrModelFamily.kt | 162 +++++- .../usvm/machine/expr/CallApproximations.kt | 25 + .../kotlin/org/usvm/machine/expr/ExprUtil.kt | 15 +- .../org/usvm/machine/expr/ReadLength.kt | 18 +- .../org/usvm/machine/expr/TsExprResolver.kt | 28 +- .../usvm/machine/interpreter/TsInterpreter.kt | 15 +- .../usvm/machine/operator/TsBinaryOperator.kt | 7 +- .../kotlin/org/usvm/machine/state/TsState.kt | 12 + .../main/kotlin/org/usvm/util/ArrayStorage.kt | 40 ++ .../kotlin/org/usvm/util/StringStorage.kt | 251 +++++++++ .../usvm/machine/call/models/StringModels.ts | 56 ++ .../call/TsArrayIsArrayIntrinsicModelTest.kt | 147 +++++ .../machine/call/TsSequenceEtsIrModelTest.kt | 116 ++++ .../kotlin/org/usvm/util/TsTestResolver.kt | 25 +- .../models/ArrayIsArrayIntrinsicModel.ts | 38 ++ .../test/resources/models/SequenceEtsIr.ts | 95 ++++ 36 files changed, 3051 insertions(+), 163 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts create mode 100644 usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt create mode 100644 usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt create mode 100644 usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index 606dab188..d9c823e53 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.usvm.PathSelectionStrategy import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput import org.usvm.ts.pbt.model.TypeScriptEntryPoint @@ -131,6 +132,7 @@ internal data class CallsSymbolicSearchRequest( val expectedNativeFrontendRevision: String, val seed: Long, val budget: Duration, + val unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, ) internal data class CallsSymbolicSearchResult( @@ -175,6 +177,7 @@ internal data class CallsRunTargetIdentity( val functionId: String, val targetId: String, val siteId: String, + val targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, ) @Serializable @@ -187,6 +190,7 @@ internal data class CallsTargetResult( val functionId: String, val targetId: String, val siteId: String, + val targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, val profile: CallsExperimentProfile, val seed: Long, val symbolicStatus: CallsSymbolicStatus, @@ -306,6 +310,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, ) } } @@ -379,6 +384,7 @@ internal class CallsExperimentRunner( target = target, seed = seed, profile = profile, + appendUnknownCall = { event -> append(rawOutput, event) }, ) append(rawOutput, result) @@ -395,18 +401,25 @@ internal class CallsExperimentRunner( target: CallsSourceTarget, seed: Long, profile: CallsExperimentProfile, + appendUnknownCall: (CallsUnknownCallRecord) -> Unit, ): CallsTargetResult { + val request = CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = profile, + frozenModelIds = manifest.modelSet.ids, + expectedNativeFrontendRevision = manifest.nativeFrontendRevision, + seed = seed, + budget = manifest.perTargetBudgetMillis.milliseconds, + ) val symbolic = symbolicEngine.search( - CallsSymbolicSearchRequest( - sourceRoot = sourceRoot, - project = project, - function = function, - target = target, - profile = profile, - frozenModelIds = manifest.modelSet.ids, - expectedNativeFrontendRevision = manifest.nativeFrontendRevision, - seed = seed, - budget = manifest.perTargetBudgetMillis.milliseconds, + request.copy( + unknownCallEventSink = callsUnknownCallEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ), ), ) val replay = symbolic.inputs?.let { inputs -> @@ -427,6 +440,7 @@ internal class CallsExperimentRunner( functionId = function.functionId, targetId = target.targetId, siteId = target.siteId, + targetMode = target.mode, profile = profile, seed = seed, symbolicStatus = symbolic.status, @@ -497,7 +511,8 @@ internal object CallsRawResultsReader { identity != null && result.revision == identity.revision && result.development == identity.development && - result.siteId == identity.siteId + result.siteId == identity.siteId && + result.targetMode == identity.targetMode }, ) { "Result target identity does not match metadata" } val resultKeys = results.map { result -> diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index 4118cc6ad..fefbdaac2 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -9,12 +9,16 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.fastcheck.FastCheckBackend import org.usvm.ts.pbt.fastcheck.PbtBackendException +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.ConstantDomain import org.usvm.ts.pbt.model.ExecutionKind import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyDefinition +import org.usvm.ts.pbt.model.PropertyDomain import org.usvm.ts.pbt.model.PropertyId import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.TupleDomain import org.usvm.ts.pbt.model.TypeScriptEntryPoint import java.nio.file.Files import java.nio.file.LinkOption @@ -52,6 +56,12 @@ internal data class CallsSourcePosition( val column: Int, ) +@Serializable +internal enum class CallsSourceTargetMode { + ENTRY, + COMPLETED_RETURN, +} + @Serializable internal data class CallsSourceTarget( val targetId: String, @@ -61,7 +71,21 @@ internal data class CallsSourceTarget( val endOffset: Int, val start: CallsSourcePosition, val end: CallsSourcePosition, -) + val mode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + val returnExpressionStartOffset: Int? = null, + val returnExpressionEndOffset: Int? = null, +) { + init { + require((returnExpressionStartOffset == null) == (returnExpressionEndOffset == null)) { + "Return expression offsets must be both present or both absent" + } + if (mode == CallsSourceTargetMode.ENTRY) { + require(returnExpressionStartOffset == null) { + "Entry targets must not declare return expression offsets" + } + } + } +} @Serializable internal data class CallsSourceReplayResult( @@ -136,9 +160,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { return try { val overlayRoot = workspace.resolve("source-overlay") val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" - val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" - val instrumented = source.substring(0, target.startOffset) + markerStatement + - source.substring(target.startOffset) + val instrumented = instrumentSource(source = source, target = target, marker = marker) createOverlay( sourceRoot = resolved.sourceRoot, overlayRoot = overlayRoot, @@ -154,6 +176,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName = entryPoint.exportName, marker = marker, resultPath = resultPath, + targetMode = target.mode, ), ) val replayRoots = sourceRoots.mapIndexed { index, root -> @@ -163,7 +186,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { val property = PropertyDefinition( id = PropertyId("calls.source-target-replay"), inputs = replayInputs.mapIndexed { index, value -> - PropertyInput(name = "input$index", domain = ConstantDomain(value)) + PropertyInput(name = "input$index", domain = value.exactReplayDomain()) }, predicate = TypeScriptEntryPoint(module = wrapperName, exportName = REPLAY_EXPORT), ) @@ -225,14 +248,39 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } private fun requireTargetCoordinates(source: String, target: CallsSourceTarget) { - require(target.startOffset in 0..source.length && target.endOffset in target.startOffset..source.length) { - "Target offsets are outside the source file" - } - require(sourcePositionAt(source = source, offset = target.startOffset) == target.start) { - "Target start coordinate does not match its source offset" + val diagnostic = callsTargetCoordinateDiagnostic(source = source, target = target) + require(diagnostic == null) { requireNotNull(diagnostic) } + } + + private fun instrumentSource( + source: String, + target: CallsSourceTarget, + marker: String, + ): String = when (target.mode) { + CallsSourceTargetMode.ENTRY -> { + val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" + + source.substring(0, target.startOffset) + markerStatement + source.substring(target.startOffset) } - require(sourcePositionAt(source = source, offset = target.endOffset) == target.end) { - "Target end coordinate does not match its source offset" + + CallsSourceTargetMode.COMPLETED_RETURN -> { + val expressionStart = target.returnExpressionStartOffset + val expressionEnd = target.returnExpressionEndOffset + if (expressionStart == null || expressionEnd == null) { + val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" + + source.substring(0, target.startOffset) + markerStatement + source.substring(target.startOffset) + } else { + val expression = source.substring(expressionStart, expressionEnd) + val wrappedExpression = """ + ((__usvm_completed_value: any) => { + (globalThis as Record)[${jsString(marker)}] = true; + return __usvm_completed_value; + })($expression) + """.trimIndent() + + source.substring(0, expressionStart) + wrappedExpression + source.substring(expressionEnd) + } } } @@ -291,6 +339,7 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { exportName: String, marker: String, resultPath: Path, + targetMode: CallsSourceTargetMode, ): String = """ import { writeFileSync } from 'node:fs'; import * as targetModule from ${jsString("./$sourcePath")}; @@ -318,7 +367,9 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { invocation = 'threw'; caught = error; } - const targetHit = (globalThis as Record)[${jsString(marker)}] === true; + const targetObserved = (globalThis as Record)[${jsString(marker)}] === true; + const targetHit = targetObserved && + (${targetMode == CallsSourceTargetMode.ENTRY} || invocation === 'returned'); const output: Record = { invocation, targetHit }; if (invocation === 'threw') { output.errorName = caught instanceof Error ? caught.name : typeof caught; @@ -344,6 +395,41 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } } +private fun JsConcreteValue.exactReplayDomain(): PropertyDomain = when (this) { + is JsConcreteValue.Array -> if (elements.isEmpty()) { + ArrayDomain(element = BooleanDomain, minLength = 0, maxLength = 0) + } else { + TupleDomain(elements.map { element -> element.exactReplayDomain() }) + } + + else -> ConstantDomain(this) +} + +internal fun callsTargetCoordinateDiagnostic(source: String, target: CallsSourceTarget): String? { + if (target.startOffset !in source.indices || target.endOffset !in 1..source.length) { + return "Target offsets are outside the frozen source" + } + if (target.startOffset >= target.endOffset) { + return "Target source range must be non-empty" + } + if (sourcePositionAt(source = source, offset = target.startOffset) != target.start) { + return "Target start coordinate does not match its frozen source offset" + } + if (sourcePositionAt(source = source, offset = target.endOffset) != target.end) { + return "Target end coordinate does not match its frozen source offset" + } + + val expressionStart = target.returnExpressionStartOffset + val expressionEnd = target.returnExpressionEndOffset + if (expressionStart != null && expressionEnd != null && + (expressionStart < target.startOffset || expressionStart >= expressionEnd || expressionEnd > target.endOffset) + ) { + return "Return expression offsets must identify a non-empty range inside the source target" + } + + return null +} + internal fun sourcePositionAt(source: String, offset: Int): CallsSourcePosition { var line = 0 var column = 0 diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt new file mode 100644 index 000000000..708719d12 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt @@ -0,0 +1,320 @@ +package org.usvm.ts.calls + +import io.ksmt.expr.KBitVec16Value +import io.ksmt.sort.KBv16Sort +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.model.EtsType +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.USort +import org.usvm.api.initializeArrayLength +import org.usvm.api.makeSymbolicPrimitive +import org.usvm.machine.TsContext +import org.usvm.machine.TsSizeSort +import org.usvm.machine.expr.extractDouble +import org.usvm.machine.expr.extractInt +import org.usvm.machine.expr.toConcreteBoolValue +import org.usvm.machine.state.TsState +import org.usvm.model.UModelBase +import org.usvm.sizeSort +import org.usvm.ts.pbt.mapping.EtsInputBinding +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.BooleanDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.PropertyDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.util.markDenseInputArray +import org.usvm.util.markStringMaxLength +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.mkFieldLValue +import org.usvm.util.mkRegisterStackLValue + +internal fun PropertyDomain.isSupportedCallsSymbolicDomain(): Boolean = when (this) { + BooleanDomain, is NumberDomain, is StringDomain -> true + is ArrayDomain -> element == BooleanDomain || element is NumberDomain + else -> false +} + +internal fun callsSymbolicInputPreflight(inputs: List): String? { + val unsupportedInput = inputs.firstOrNull { input -> !input.domain.isSupportedCallsSymbolicDomain() } + ?: return null + + return "Unsupported symbolic input domain for ${unsupportedInput.name}: ${unsupportedInput.domain}" +} + +internal class CallsSymbolicInputs( + inputs: List, + bindings: List, +) { + private val boundInputs: List> + private var snapshots: List? = null + + init { + require(inputs.size == bindings.size) { "Property inputs do not match mapped EtsIR bindings" } + val bindingsByName = bindings.associateBy(EtsInputBinding::propertyInputName) + require(bindingsByName.size == bindings.size) { "Mapped EtsIR input names must be unique" } + + boundInputs = inputs.map { input -> + input to requireNotNull(bindingsByName[input.name]) { + "Missing EtsIR binding for property input ${input.name}" + } + } + } + + fun initialize(state: TsState) { + check(snapshots == null) { "Calls symbolic inputs were initialized more than once" } + snapshots = boundInputs.map { (input, binding) -> + state.initializeInput( + stackSlot = binding.stackSlot, + domain = input.domain, + ) + } + } + + fun sortOverride(ctx: TsContext, stackSlot: Int): USort? { + val domain = boundInputs.singleOrNull { (_, binding) -> binding.stackSlot == stackSlot }?.first?.domain + ?: return null + + return with(ctx) { + when (domain) { + BooleanDomain -> boolSort + is NumberDomain -> fp64Sort + is StringDomain, is ArrayDomain -> addressSort + else -> null + } + } + } + + fun resolve(state: TsState): List { + val initializedSnapshots = checkNotNull(snapshots) { "Calls symbolic inputs were not initialized" } + val model = state.models.single() + + return initializedSnapshots.map { snapshot -> snapshot.resolve(model) } + } +} + +private fun TsState.initializeInput( + stackSlot: Int, + domain: PropertyDomain, +): CallsInputSnapshot = with(ctx) { + when (domain) { + BooleanDomain -> { + val value: UBoolExpr = makeSymbolicPrimitive(boolSort) + memory.write( + mkRegisterStackLValue(boolSort, stackSlot), + value.asExpr(boolSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, boolSort) + BooleanInputSnapshot(value) + } + + is NumberDomain -> { + val value: UExpr = makeSymbolicPrimitive(fp64Sort) + pathConstraints += numberDomainConstraint(value, domain) + memory.write( + mkRegisterStackLValue(fp64Sort, stackSlot), + value.asExpr(fp64Sort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, fp64Sort) + NumberInputSnapshot(value) + } + + is StringDomain -> { + initializeStringInput(stackSlot = stackSlot, domain = domain) + } + + is ArrayDomain -> { + initializeArrayInput(stackSlot = stackSlot, domain = domain) + } + + else -> { + error("Unsupported calls symbolic domain: $domain") + } + } +} + +private fun TsState.initializeStringInput( + stackSlot: Int, + domain: StringDomain, +): StringInputSnapshot = with(ctx) { + val stringRef = memory.allocConcrete(EtsStringType) + val characterArrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val descriptor = arrayDescriptorOf(characterArrayType) + val charactersRef = memory.allocConcrete(descriptor) + val length: UExpr = makeSymbolicPrimitive(sizeSort) + val codeUnits: List> = List(domain.maxLength) { makeSymbolicPrimitive(bv16Sort) } + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + memory.initializeArrayLength( + arrayHeapRef = charactersRef, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + codeUnits.forEachIndexed { index, codeUnit -> + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + memory.write( + mkArrayIndexLValue( + sort = bv16Sort, + ref = charactersRef, + index = mkBv(index), + type = characterArrayType, + ), + codeUnit, + guard = liveIndex, + ) + } + memory.write( + mkFieldLValue(addressSort, stringRef, "value"), + charactersRef.asExpr(addressSort), + guard = trueExpr, + ) + markStringMaxLength(string = stringRef, maxLength = domain.maxLength) + memory.write( + mkRegisterStackLValue(addressSort, stackSlot), + stringRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, addressSort) + + StringInputSnapshot(length = length, codeUnits = codeUnits) +} + +private fun TsState.initializeArrayInput( + stackSlot: Int, + domain: ArrayDomain, +): ArrayInputSnapshot = with(ctx) { + val runtimeType = when (domain.element) { + BooleanDomain -> EtsArrayType(EtsBooleanType, dimensions = 1) + is NumberDomain -> EtsArrayType(EtsNumberType, dimensions = 1) + else -> error("Unsupported calls symbolic array element domain: ${domain.element}") + } + val descriptor = arrayDescriptorOf(runtimeType) + val arrayRef = memory.allocConcrete(descriptor) + val length: UExpr = makeSymbolicPrimitive(sizeSort) + + constrainLength(length = length, minLength = domain.minLength, maxLength = domain.maxLength) + memory.initializeArrayLength( + arrayHeapRef = arrayRef, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + val elements = when (val elementDomain = domain.element) { + BooleanDomain -> List(domain.maxLength) { index -> + val value = makeSymbolicPrimitive(boolSort) + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + memory.write( + mkArrayIndexLValue(boolSort, arrayRef, mkBv(index), runtimeType), + value, + guard = liveIndex, + ) + BooleanInputSnapshot(value) + } + + is NumberDomain -> List(domain.maxLength) { index -> + val value = makeSymbolicPrimitive(fp64Sort) + val liveIndex = mkBvSignedLessExpr(mkBv(index), length) + pathConstraints += numberDomainConstraint(value, elementDomain) + memory.write( + mkArrayIndexLValue(fp64Sort, arrayRef, mkBv(index), runtimeType), + value, + guard = liveIndex, + ) + NumberInputSnapshot(value) + } + + else -> error("Unsupported calls symbolic array element domain: $elementDomain") + } + memory.write( + mkRegisterStackLValue(addressSort, stackSlot), + arrayRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(stackSlot, addressSort) + markDenseInputArray(array = arrayRef, type = runtimeType) + + ArrayInputSnapshot(length = length, elements = elements) +} + +private fun TsState.constrainLength( + length: UExpr, + minLength: Int, + maxLength: Int, +) = with(ctx) { + pathConstraints += mkBvSignedGreaterOrEqualExpr(length, mkBv(minLength)) + pathConstraints += mkBvSignedLessOrEqualExpr(length, mkBv(maxLength)) +} + +private fun TsState.numberDomainConstraint( + value: UExpr, + domain: NumberDomain, +): UBoolExpr = with(ctx) { + val lowerBound = mkFp64(domain.min.toDouble()) + val upperBound = mkFp64(domain.max.toDouble()) + val inBounds = mkAnd( + mkFpLessOrEqualExpr(lowerBound, value), + mkFpLessOrEqualExpr(value, upperBound), + ) + + if (domain.allowNaN) mkOr(mkFpIsNaNExpr(value), inBounds) else inBounds +} + +private sealed interface CallsInputSnapshot { + fun resolve(model: UModelBase): JsConcreteValue +} + +private data class BooleanInputSnapshot( + val value: UBoolExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.Boolean(model.eval(value).toConcreteBoolValue()) +} + +private data class NumberInputSnapshot( + val value: UExpr, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue = + JsConcreteValue.number(model.eval(value).extractDouble()) +} + +private data class StringInputSnapshot( + val length: UExpr, + val codeUnits: List>, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue { + val concreteLength = model.eval(length).extractInt() + require(concreteLength in 0..codeUnits.size) { "Resolved string length is outside its symbolic domain" } + val value = buildString(concreteLength) { + codeUnits.take(concreteLength).forEach { codeUnit -> + val concreteCodeUnit = model.eval(codeUnit) as KBitVec16Value + append((concreteCodeUnit.shortValue.toInt() and UTF16_CODE_UNIT_MASK).toChar()) + } + } + + return JsConcreteValue.String(value) + } +} + +private data class ArrayInputSnapshot( + val length: UExpr, + val elements: List, +) : CallsInputSnapshot { + override fun resolve(model: UModelBase): JsConcreteValue { + val concreteLength = model.eval(length).extractInt() + require(concreteLength in 0..elements.size) { "Resolved array length is outside its symbolic domain" } + + return JsConcreteValue.Array(elements.take(concreteLength).map { element -> element.resolve(model) }) + } +} + +private const val UTF16_CODE_UNIT_MASK = 0xffff diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt new file mode 100644 index 000000000..e110d0a2b --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt @@ -0,0 +1,154 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNamespaceSignature +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent + +@Serializable +internal data class CallsExperimentCellIdentity( + val experimentId: String, + val projectId: String, + val revision: String, + val development: Boolean, + val functionId: String, + val targetId: String, + val siteId: String, + val targetMode: CallsSourceTargetMode, + val profile: CallsExperimentProfile, + val seed: Long, +) + +@Serializable +internal data class CallsUnknownCallSite( + val sourcePath: String, + val statementIndex: Int, + val startOffset: Int? = null, + val endOffset: Int? = null, + val start: CallsSourcePosition? = null, + val end: CallsSourcePosition? = null, +) + +@Serializable +internal data class CallsUnknownCallCallee( + val calleeId: String, + val sourcePath: String, + val enclosingClass: String, + val name: String, + val parameterTypes: List, + val returnType: String, +) + +@Serializable +internal enum class CallsUnknownCallDecisionKind { + MODEL_APPLIED, + RESIDUAL_FALLBACK, +} + +@Serializable +@SerialName("unknown-call") +internal data class CallsUnknownCallRecord( + val cell: CallsExperimentCellIdentity, + val eventIndex: Int, + val callSite: CallsUnknownCallSite, + val callee: CallsUnknownCallCallee, + val failureReason: String, + val decision: CallsUnknownCallDecisionKind, + val outcome: String, + val modelId: String? = null, + val residualPolicy: String? = null, +) : CallsRawRecord { + init { + require(eventIndex >= 1) { "Unknown-call event index must be positive" } + require((decision == CallsUnknownCallDecisionKind.MODEL_APPLIED) == (modelId != null)) { + "Exactly a model-applied decision must carry a model ID" + } + require((decision == CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK) == (residualPolicy != null)) { + "Exactly a residual-fallback decision must carry a residual policy" + } + } +} + +internal fun CallsSymbolicSearchRequest.cellIdentity(experimentId: String): CallsExperimentCellIdentity = + CallsExperimentCellIdentity( + experimentId = experimentId, + projectId = project.projectId, + revision = project.revision, + development = project.development, + functionId = function.functionId, + targetId = target.targetId, + siteId = target.siteId, + targetMode = target.mode, + profile = profile, + seed = seed, + ) + +internal fun CallsExperimentCellIdentity.unknownCallRecord( + event: TsUnknownCallEvent, + eventIndex: Int, +): CallsUnknownCallRecord { + val containingMethod = event.callSite.location.method.signature + val origin = event.callSite.location.origin + val decision = event.decision + + return CallsUnknownCallRecord( + cell = this, + eventIndex = eventIndex, + callSite = CallsUnknownCallSite( + sourcePath = containingMethod.enclosingClass.file.fileName, + statementIndex = event.callSite.location.index, + startOffset = origin?.startOffset, + endOffset = origin?.endOffset, + start = origin?.let { span -> CallsSourcePosition(line = span.startLine, column = span.startColumn) }, + end = origin?.let { span -> CallsSourcePosition(line = span.endLine, column = span.endColumn) }, + ), + callee = event.callee.toCallsUnknownCallCallee(), + failureReason = event.failureReason.name, + decision = when (decision) { + is TsUnknownCallDecision.ModelApplied -> CallsUnknownCallDecisionKind.MODEL_APPLIED + is TsUnknownCallDecision.ResidualFallback -> CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK + }, + outcome = event.outcome.name, + modelId = (decision as? TsUnknownCallDecision.ModelApplied)?.modelId, + residualPolicy = (decision as? TsUnknownCallDecision.ResidualFallback)?.policy?.serializedName, + ) +} + +internal fun callsUnknownCallEventSink( + cell: CallsExperimentCellIdentity, + appendAndFlush: (CallsUnknownCallRecord) -> Unit, +): (TsUnknownCallEvent) -> Unit { + var eventIndex = 0 + + return { event -> + eventIndex++ + appendAndFlush(cell.unknownCallRecord(event = event, eventIndex = eventIndex)) + } +} + +private fun EtsMethodSignature.toCallsUnknownCallCallee(): CallsUnknownCallCallee { + val parameterTypes = parameters.map { parameter -> parameter.type.toString() } + val namespace = enclosingClass.namespace?.qualifiedName() + val className = listOfNotNull(namespace, enclosingClass.name).joinToString(separator = "::") + val sourcePath = enclosingClass.file.fileName + val returnType = returnType.toString() + val signature = "$className:$name(${parameterTypes.joinToString(separator = ",")})->$returnType" + + return CallsUnknownCallCallee( + calleeId = "$sourcePath:$signature", + sourcePath = sourcePath, + enclosingClass = className, + name = name, + parameterTypes = parameterTypes, + returnType = returnType, + ) +} + +private fun EtsNamespaceSignature.qualifiedName(): String = + listOfNotNull(namespace?.qualifiedName(), name).joinToString(separator = "::") + +private val TsResidualCallPolicy.serializedName: String + get() = name diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 69b6cc469..0bcc90bbb 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -1,9 +1,8 @@ package org.usvm.ts.calls -import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsMethod -import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsReturnStmt import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.utils.EtsIrProvider @@ -12,46 +11,90 @@ import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.machine.TsAnalysisStopReason +import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallModelSelection -import org.usvm.machine.expr.extractDouble -import org.usvm.machine.expr.toConcreteBoolValue +import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.statistics.UMachineObserver +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import org.usvm.ts.pbt.manifest.PropertyManifest +import org.usvm.ts.pbt.mapping.EtsInputBinding import org.usvm.ts.pbt.mapping.EtsMappingStatus import org.usvm.ts.pbt.mapping.PropertyEtsMapper -import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.NumberDomain import org.usvm.ts.pbt.model.contains -import org.usvm.util.mkRegisterStackLValue import java.nio.file.Path import kotlin.time.TimeSource +internal data class CallsSymbolicPreflightRequest( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val target: CallsSourceTarget, + val expectedNativeFrontendRevision: String, +) + +internal enum class CallsSymbolicPreflightStatus { + ELIGIBLE, + UNSUPPORTED, + UNMAPPED, + AMBIGUOUS, + TOOL_ERROR, +} + +internal enum class CallsSymbolicPreflightReasonCode { + INPUT_DOMAIN_UNSUPPORTED, + IMPORTED_CALLEES_UNSUPPORTED, + ENTRY_MAPPING_UNSUPPORTED, + ENTRY_MAPPING_UNMAPPED, + ENTRY_MAPPING_AMBIGUOUS, + LEXICAL_CAPTURE_UNSUPPORTED, + TARGET_ORIGIN_UNMAPPED, + TARGET_ORIGIN_UNSUPPORTED, + FRONTEND_OR_PREFLIGHT_ERROR, +} + +internal data class CallsSymbolicPreflightResult( + val status: CallsSymbolicPreflightStatus, + val reasonCode: CallsSymbolicPreflightReasonCode? = null, + val diagnostic: String? = null, +) { + init { + require((status == CallsSymbolicPreflightStatus.ELIGIBLE) == (reasonCode == null)) { + "Eligible preflight has no exclusion reason; rejected preflight has exactly one" + } + } +} + internal class CurrentTsCallsSymbolicEngine( private val environment: (String) -> String? = System::getenv, private val bundledNativeFrontendRevision: String = CallsBuildIdentity.nativeFrontendRevision, ) : CallsSymbolicEngine { private val verifiedProjects = mutableMapOf() + private val preparedTargets = mutableMapOf() private var verifiedNativeFrontendIdentity: String? = null override fun search(request: CallsSymbolicSearchRequest): CallsSymbolicSearchResult { val startedAt = TimeSource.Monotonic.markNow() - val unsupportedInput = request.function.inputs.firstOrNull { input -> - input.domain != BooleanDomain && input.domain !is NumberDomain - } - if (unsupportedInput != null) { + val preflightRequest = request.toPreflightRequest() + val preparation = prepareSafely(preflightRequest) + if (preparation is CallsTargetPreparation.Rejected) { return result( - status = CallsSymbolicStatus.UNSUPPORTED, + status = preparation.status, startedAt = startedAt, - diagnostic = "Only boolean and number input domains are supported; found ${unsupportedInput.domain}", + diagnostic = preparation.diagnostic, ) } return runCatching { - searchSupported(request = request, startedAt = startedAt) + searchSupported( + request = request, + startedAt = startedAt, + prepared = preparation as CallsTargetPreparation.Eligible, + ) }.getOrElse { error -> result( status = CallsSymbolicStatus.TOOL_ERROR, @@ -61,64 +104,28 @@ internal class CurrentTsCallsSymbolicEngine( } } + fun preflight(request: CallsSymbolicPreflightRequest): CallsSymbolicPreflightResult = + when (val preparation = prepareSafely(request)) { + is CallsTargetPreparation.Eligible -> CallsSymbolicPreflightResult( + status = CallsSymbolicPreflightStatus.ELIGIBLE, + ) + + is CallsTargetPreparation.Rejected -> CallsSymbolicPreflightResult( + status = preparation.status.toPreflightStatus(), + reasonCode = preparation.reasonCode, + diagnostic = preparation.diagnostic, + ) + } + @Suppress("LongMethod") private fun searchSupported( request: CallsSymbolicSearchRequest, startedAt: TimeSource.Monotonic.ValueTimeMark, + prepared: CallsTargetPreparation.Eligible, ): CallsSymbolicSearchResult { - verifyGitCheckoutOnce( - checkout = request.sourceRoot, - expectedRevision = request.project.revision, - cache = verifiedProjects, - ) - verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) - - val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() - require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } - - val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) - if (sourceFile.importInfos.isNotEmpty()) { - return result( - status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "Single-file symbolic replay does not support imported project callees", - ) - } - val scene = EtsScene(projectFiles = listOf(sourceFile)) - val frontendEntryPoint = request.function.entryPoint.copy( - module = requireNotNull(source.fileName).toString(), - ) - val propertyManifest = PropertyManifest( - propertyId = "calls.mapping", - inputs = request.function.inputs, - predicate = frontendEntryPoint, - ) - val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) - if (mapping.predicate.status != EtsMappingStatus.EXACT) { - return result( - status = mapping.predicate.status.toSymbolicStatus(), - startedAt = startedAt, - diagnostic = mapping.predicate.diagnostics.joinToString { diagnostic -> diagnostic.message }, - ) - } - - val method = mapping.predicate.targets.single().method - val exactTargetCandidates = exactTargetCandidates(method, request.target) - if (exactTargetCandidates.isEmpty()) { - return result( - status = CallsSymbolicStatus.UNMAPPED, - startedAt = startedAt, - diagnostic = "No EtsIR statement has the exact frozen source range", - ) - } - val statementEntry = sourceStatementEntry(method, request.target) - if (statementEntry == null) { - return result( - status = CallsSymbolicStatus.UNSUPPORTED, - startedAt = startedAt, - diagnostic = "EtsIR origins do not prove entry before evaluation of the frozen source statement", - ) - } + val scene = prepared.scene + val method = prepared.method + val targetObserver = prepared.targetObservation.createObserver(method) val modelSelection = if (request.profile.usesFrozenModels) { TsUnknownCallModelSelection.Only(request.frozenModelIds) @@ -139,18 +146,23 @@ internal class CurrentTsCallsSymbolicEngine( unknownCallModelSelection = modelSelection, unknownCallFallback = request.profile.fallback, ) - // Observe the unique CFG entry into the source statement's origin-contained lowering region. - // This matches the replay marker before statement evaluation, including nested constructor and call lowering. - val entryObserver = SourceStatementEntryObserver(statementEntry.statement) + val symbolicInputs = CallsSymbolicInputs( + inputs = request.function.inputs, + bindings = prepared.inputBindings, + ) + val interpreterObserver = request.unknownCallEventSink?.let(::UnknownCallEventSinkObserver) val analysis = TsMachine( scene = scene, options = machineOptions, tsOptions = tsOptions, - machineObserver = entryObserver, + initialStateConfigurator = symbolicInputs::initialize, + initialParameterSortOverride = symbolicInputs::sortOverride, + machineObserver = targetObserver, + observer = interpreterObserver, ).use { machine -> val outcome = machine.analyzeWithOutcome(methods = listOf(method)) MachineResult( - states = entryObserver.reachedStates, + states = targetObserver.reachedStates, stopReason = outcome.stopReason, ) } @@ -169,7 +181,7 @@ internal class CurrentTsCallsSymbolicEngine( } val inputs = states.asSequence() - .map { state -> resolveScalarInputs(state, method) } + .map(symbolicInputs::resolve) .firstOrNull { candidate -> candidate.zip(request.function.inputs).all { (value, input) -> value in input.domain } } @@ -186,8 +198,158 @@ internal class CurrentTsCallsSymbolicEngine( status = CallsSymbolicStatus.REACHED, inputs = inputs, startedAt = startedAt, - diagnostic = "source-statement-lowering-size=${statementEntry.loweringSize};" + - "exact-source-lowering-size=${exactTargetCandidates.size}", + diagnostic = "source-target-mode=${request.target.mode};" + + "source-statement-lowering-size=${targetObserver.loweringSize};" + + "exact-source-lowering-size=${prepared.exactTargetCandidateCount}", + ) + } + + private fun prepareSafely(request: CallsSymbolicPreflightRequest): CallsTargetPreparation { + preparedTargets[request]?.let { preparation -> return preparation } + + val preparation = runCatching { prepare(request) }.getOrElse { error -> + CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.TOOL_ERROR, + reasonCode = CallsSymbolicPreflightReasonCode.FRONTEND_OR_PREFLIGHT_ERROR, + diagnostic = error.message ?: error::class.java.name, + ) + } + preparedTargets[request] = preparation + + return preparation + } + + @Suppress("LongMethod") + private fun prepare(request: CallsSymbolicPreflightRequest): CallsTargetPreparation { + callsSymbolicInputPreflight(request.function.inputs)?.let { diagnostic -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.INPUT_DOMAIN_UNSUPPORTED, + diagnostic = diagnostic, + ) + } + + verifyGitCheckoutOnce( + checkout = request.sourceRoot, + expectedRevision = request.project.revision, + cache = verifiedProjects, + ) + verifyNativeFrontendOnce(expectedRevision = request.expectedNativeFrontendRevision) + + val source = request.sourceRoot.resolve(request.function.sourceFile).normalize() + require(source.startsWith(request.sourceRoot)) { "Function source escapes its frozen source root" } + val sourceText = java.nio.file.Files.readString(source) + callsTargetCoordinateDiagnostic(source = sourceText, target = request.target)?.let { diagnostic -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNMAPPED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, + diagnostic = diagnostic, + ) + } + + val sourceFile = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + if (sourceFile.importInfos.isNotEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.IMPORTED_CALLEES_UNSUPPORTED, + diagnostic = "Single-file symbolic replay does not support imported project callees", + ) + } + + val scene = EtsScene(projectFiles = listOf(sourceFile)) + val frontendEntryPoint = request.function.entryPoint.copy( + module = requireNotNull(source.fileName).toString(), + ) + val propertyManifest = PropertyManifest( + propertyId = "calls.mapping", + inputs = request.function.inputs, + predicate = frontendEntryPoint, + ) + val mapping = PropertyEtsMapper(scene = scene, sourceRoots = listOf(request.sourceRoot)).map(propertyManifest) + if (mapping.predicate.status != EtsMappingStatus.EXACT) { + return CallsTargetPreparation.Rejected( + status = mapping.predicate.status.toSymbolicStatus(), + reasonCode = mapping.predicate.status.toPreflightReasonCode(), + diagnostic = mapping.predicate.diagnostics.joinToString { diagnostic -> diagnostic.message }, + ) + } + + val mappedTarget = mapping.predicate.targets.single() + val lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment + if (lexicalEnvironment != null && lexicalEnvironment.parameter.type !is EtsLexicalEnvType) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, + diagnostic = "Mapped lexical environment does not have a lexical-environment type", + ) + } + + val unsupportedCaptures = (lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType) + ?.closures + ?.map { closure -> closure.name } + ?.filterNot { closure -> closure in SUPPORTED_BUILTIN_CAPTURES } + .orEmpty() + if (unsupportedCaptures.isNotEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, + diagnostic = "Symbolic entry point has unsupported runtime captures: " + + unsupportedCaptures.joinToString(), + ) + } + + val method = mappedTarget.method + val exactTargetCandidates = exactTargetCandidates(method, request.target) + val targetObservation = when (request.target.mode) { + CallsSourceTargetMode.ENTRY -> { + if (exactTargetCandidates.isEmpty()) { + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNMAPPED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, + diagnostic = "No EtsIR statement has the exact frozen source range", + ) + } + + val statementEntry = sourceStatementEntry(method, request.target) + ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "EtsIR origins do not prove entry before evaluation " + + "of the frozen source statement", + ) + + SourceTargetObservation.Entry(statementEntry) + } + + CallsSourceTargetMode.COMPLETED_RETURN -> { + val returnStatement = completedReturnCandidate( + method = method, + target = request.target, + exactTargetCandidates = exactTargetCandidates, + allowsUniqueOriginlessReturn = { + TypeScriptSourceInspector.isExportedExpressionArrowBody( + source = source, + exportName = request.function.entryPoint.exportName, + startOffset = request.target.startOffset, + endOffset = request.target.endOffset, + ) + }, + ) ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "EtsIR does not identify one requested return statement", + ) + + SourceTargetObservation.CompletedReturn(returnStatement) + } + } + + return CallsTargetPreparation.Eligible( + scene = scene, + method = method, + inputBindings = mappedTarget.bindings.inputs, + targetObservation = targetObservation, + exactTargetCandidateCount = exactTargetCandidates.size, ) } @@ -202,29 +364,77 @@ internal class CurrentTsCallsSymbolicEngine( origin.endColumn == target.end.column } - private fun resolveScalarInputs(state: TsState, method: EtsMethod): List = with(state.ctx) { - val model = state.models.single() + private fun completedReturnCandidate( + method: EtsMethod, + target: CallsSourceTarget, + exactTargetCandidates: List, + allowsUniqueOriginlessReturn: () -> Boolean, + ): EtsReturnStmt? { + val exactReturns = exactTargetCandidates.filterIsInstance() + if (exactReturns.isNotEmpty()) { + return exactReturns.singleOrNull() + } - method.parameters.mapIndexed { index, parameter -> - val stackIndex = index + 1 - when (parameter.type) { - EtsNumberType -> { - val lValue = mkRegisterStackLValue(fp64Sort, stackIndex) - val value = model.eval(state.memory.read(lValue).asExpr(fp64Sort)).extractDouble() - JsConcreteValue.number(value) - } + val expressionStart = target.returnExpressionStartOffset ?: return null + val expressionEnd = target.returnExpressionEndOffset ?: return null + val targetIsExpression = target.startOffset == expressionStart && target.endOffset == expressionEnd + if (!targetIsExpression) { + return null + } - EtsBooleanType -> { - val lValue = mkRegisterStackLValue(boolSort, stackIndex) - val value = model.eval(state.memory.read(lValue).asExpr(boolSort)).toConcreteBoolValue() - JsConcreteValue.Boolean(value) - } + val containingReturns = method.cfg.stmts.filterIsInstance().filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + } + val smallestContainingRange = containingReturns.minOfOrNull { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset + } + if (smallestContainingRange != null) { + val smallestReturns = containingReturns.filter { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset == smallestContainingRange + } + if (smallestReturns.isNotEmpty()) { + return smallestReturns.singleOrNull() + } + } - else -> { - error("Unsupported scalar parameter type: ${parameter.type}") - } + val containingStatements = method.cfg.stmts.filter { statement -> + val origin = statement.location.origin ?: return@filter false + origin.startOffset <= expressionStart && origin.endOffset >= expressionEnd + } + val smallestStatementRange = containingStatements.minOfOrNull { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset + } ?: return if (allowsUniqueOriginlessReturn()) { + method.cfg.stmts.filterIsInstance().singleOrNull() + } else { + null + } + val anchors = containingStatements.filter { statement -> + val origin = requireNotNull(statement.location.origin) + origin.endOffset - origin.startOffset == smallestStatementRange + } + + val reachableReturns = mutableSetOf() + val visited = mutableSetOf() + val pending = ArrayDeque() + pending.addAll(anchors) + while (pending.isNotEmpty()) { + val statement = pending.removeFirst() + if (!visited.add(statement)) { + continue + } + if (statement is EtsReturnStmt) { + reachableReturns += statement + continue } + + pending.addAll(method.cfg.successors(statement)) } + + return reachableReturns.singleOrNull() } private fun result( @@ -284,18 +494,81 @@ internal class CurrentTsCallsSymbolicEngine( cache[checkout] = expectedRevision } + private sealed interface CallsTargetPreparation { + data class Eligible( + val scene: EtsScene, + val method: EtsMethod, + val inputBindings: List, + val targetObservation: SourceTargetObservation, + val exactTargetCandidateCount: Int, + ) : CallsTargetPreparation + + data class Rejected( + val status: CallsSymbolicStatus, + val reasonCode: CallsSymbolicPreflightReasonCode, + val diagnostic: String, + ) : CallsTargetPreparation + } + + private sealed interface SourceTargetObservation { + fun createObserver(method: EtsMethod): SourceTargetObserver + + data class Entry(val entry: SourceStatementEntry) : SourceTargetObservation { + override fun createObserver(method: EtsMethod): SourceTargetObserver = + SourceStatementEntryObserver(entry) + } + + data class CompletedReturn(val statement: EtsReturnStmt) : SourceTargetObservation { + override fun createObserver(method: EtsMethod): SourceTargetObserver = + SourceCompletedReturnObserver(method = method, target = statement) + } + } + + private interface SourceTargetObserver : UMachineObserver { + val reachedStates: List + val loweringSize: Int + } + private class SourceStatementEntryObserver( - private val target: EtsStmt, - ) : UMachineObserver { - val reachedStates = mutableListOf() + private val target: SourceStatementEntry, + ) : SourceTargetObserver { + override val reachedStates = mutableListOf() + override val loweringSize: Int = target.loweringSize override fun onStatePeeked(state: TsState) { - if (state.currentStatement == target) { + if (state.currentStatement == target.statement) { reachedStates += state.clone() } } } + private class SourceCompletedReturnObserver( + private val method: EtsMethod, + private val target: EtsReturnStmt, + ) : SourceTargetObserver { + override val reachedStates = mutableListOf() + override val loweringSize: Int = 1 + + override fun onState(state: TsState, forks: Sequence) { + sequenceOf(state).plus(forks) + .filter { candidate -> candidate.currentStatement == target } + .filter { candidate -> candidate.callStack.isEmpty() } + .filter { candidate -> + val result = candidate.methodResult + result is TsMethodResult.Success.RegularCall && result.method == method + } + .mapTo(reachedStates) { candidate -> candidate.clone() } + } + } + + private class UnknownCallEventSinkObserver( + private val sink: (TsUnknownCallEvent) -> Unit, + ) : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + sink(event) + } + } + private data class MachineResult( val states: List, val stopReason: TsAnalysisStopReason, @@ -303,9 +576,35 @@ internal class CurrentTsCallsSymbolicEngine( private companion object { const val BUNDLED_FRONTEND_PREFIX: String = "bundled:" + + val SUPPORTED_BUILTIN_CAPTURES: Set = setOf( + "Array", + "Boolean", + "Error", + "Math", + "Number", + "Object", + "String", + ) } } +private fun CallsSymbolicSearchRequest.toPreflightRequest() = CallsSymbolicPreflightRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + expectedNativeFrontendRevision = expectedNativeFrontendRevision, +) + +private fun CallsSymbolicStatus.toPreflightStatus(): CallsSymbolicPreflightStatus = when (this) { + CallsSymbolicStatus.UNSUPPORTED -> CallsSymbolicPreflightStatus.UNSUPPORTED + CallsSymbolicStatus.UNMAPPED -> CallsSymbolicPreflightStatus.UNMAPPED + CallsSymbolicStatus.AMBIGUOUS -> CallsSymbolicPreflightStatus.AMBIGUOUS + CallsSymbolicStatus.TOOL_ERROR -> CallsSymbolicPreflightStatus.TOOL_ERROR + else -> error("Symbolic execution status $this is not a preflight result") +} + internal data class SourceStatementEntry( val statement: EtsStmt, val loweringSize: Int, @@ -387,3 +686,10 @@ private fun EtsMappingStatus.toSymbolicStatus(): CallsSymbolicStatus = when (thi EtsMappingStatus.UNMAPPED -> CallsSymbolicStatus.UNMAPPED EtsMappingStatus.UNSUPPORTED -> CallsSymbolicStatus.UNSUPPORTED } + +private fun EtsMappingStatus.toPreflightReasonCode(): CallsSymbolicPreflightReasonCode = when (this) { + EtsMappingStatus.EXACT -> error("Exact mapping has no exclusion reason") + EtsMappingStatus.AMBIGUOUS -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_AMBIGUOUS + EtsMappingStatus.UNMAPPED -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_UNMAPPED + EtsMappingStatus.UNSUPPORTED -> CallsSymbolicPreflightReasonCode.ENTRY_MAPPING_UNSUPPORTED +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt index d4307c608..97f4fdb23 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsExperimentTest.kt @@ -2,8 +2,14 @@ package org.usvm.ts.calls import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue import org.usvm.ts.pbt.model.PropertyInput @@ -17,6 +23,82 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CallsExperimentTest { + @Test + fun `runner persists call events before search returns without counting them as results`(@TempDir directory: Path) { + val source = Path.of(checkNotNull(javaClass.getResource("/calls/SourceTargetReplayFixture.ts")).toURI()) + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val method = file.allClasses.flatMap { cls -> cls.methods } + .single { candidate -> candidate.name == "completesReturnExpression" } + val callSite = method.cfg.stmts.first { statement -> statement.location.origin != null } + val rawOutput = directory.resolve("results.jsonl") + val partialOutput = directory.resolve("results.jsonl.partial") + val event = TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(policy = TsResidualCallPolicy.STOP_PATH), + ) + val engine = CallsSymbolicEngine { request -> + checkNotNull(request.unknownCallEventSink).invoke(event) + val appended = readRecords(partialOutput).last() as CallsUnknownCallRecord + assertEquals(request.profile, appended.cell.profile) + assertEquals(1, appended.eventIndex) + + result(status = CallsSymbolicStatus.UNREACHED) + } + + CallsExperimentRunner( + symbolicEngine = engine, + targetReplayer = CallsTargetReplayer { _, _, _, _, _ -> error("No witness to replay") }, + runtimeToolRevision = FIXTURE_TOOL_REVISION, + ).run( + manifest = manifest(sourceRoot = ".", seeds = listOf(17L)), + manifestDirectory = directory, + rawOutput = rawOutput, + ) + + val records = readRecords(rawOutput) + assertEquals(4, records.filterIsInstance().size) + assertEquals(4, CallsExperimentAggregator.summarize(rawOutput).resultRows) + } + + @Test + fun `legacy source target defaults to entry mode`() { + val encoded = """ + { + "targetId": "target", + "siteId": "site", + "sourcePath": "fixture.ts", + "startOffset": 0, + "endOffset": 6, + "start": { "line": 0, "column": 0 }, + "end": { "line": 0, "column": 6 } + } + """.trimIndent() + + val target = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(CallsSourceTargetMode.ENTRY, target.mode) + assertNull(target.returnExpressionStartOffset) + assertNull(target.returnExpressionEndOffset) + } + + @Test + fun `completed return target preserves explicit expression offsets`() { + val entryTarget = manifest(sourceRoot = ".", seeds = listOf(17L)) + .projects.single().functions.single().targets.single() + val completedReturn = entryTarget.copy( + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpressionStartOffset = 7, + returnExpressionEndOffset = 10, + ) + + val encoded = CallsExperimentJson.json.encodeToString(completedReturn) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) + + assertEquals(completedReturn, decoded) + } + @Test fun `manifest rejects breadth first search`() { val accepted = manifest(sourceRoot = ".", seeds = listOf(17L)) diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt index 7f764c3a7..1e5a38645 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt @@ -53,6 +53,87 @@ class CallsSourceReplayTest { assertEquals(true, invoked.invocation?.targetHit) } + @Test + fun `completed return marks only after its full expression evaluates`() { + val fixture = fixture() + val target = fixture.target( + functionName = "completesReturnExpression", + statement = "return /* before expression */ value.trim() /* after expression */", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value.trim()", + ) + + val replay = fixture.replay( + exportName = "completesReturnExpression", + inputs = listOf(JsConcreteValue.String(" value ")), + target = target, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("returned", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects when its expression throws`() { + val fixture = fixture() + val target = fixture.target( + functionName = "expressionThrowsBeforeReturnCompletes", + statement = "return throwFromExpression();", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "throwFromExpression()", + ) + + val replay = fixture.replay( + exportName = "expressionThrowsBeforeReturnCompletes", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(false, replay.invocation?.targetHit) + assertEquals("expression failed", replay.invocation?.errorMessage) + } + + @Test + fun `completed return rejects when a finally block throws`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyThrowsAfterReturnExpression", + statement = "return 'value'.trim();", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "'value'.trim()", + ) + + val replay = fixture.replay( + exportName = "finallyThrowsAfterReturnExpression", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(false, replay.invocation?.targetHit) + assertEquals("finally failed", replay.invocation?.errorMessage) + } + + @Test + fun `completed bare return tolerates comments and omitted semicolon`() { + val fixture = fixture() + val target = fixture.target( + functionName = "completesBareReturn", + statement = "return /* no expression */", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val replay = fixture.replay(exportName = "completesBareReturn", inputs = emptyList(), target = target) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("returned", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + } + private fun fixture(): Fixture { val sourcePath = resourcePath("/calls/SourceTargetReplayFixture.ts") @@ -76,11 +157,24 @@ class CallsSourceReplayTest { val sourcePath: Path, val source: String, ) { - fun target(functionName: String, statement: String): CallsSourceTarget { + fun target( + functionName: String, + statement: String, + mode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + returnExpression: String? = null, + ): CallsSourceTarget { val functionStart = source.indexOf("function $functionName") val startOffset = source.indexOf(statement, startIndex = functionStart) check(functionStart >= 0 && startOffset >= 0) { "Missing $statement in $functionName" } val endOffset = startOffset + statement.length + val returnExpressionStartOffset = returnExpression?.let { expression -> + source.indexOf(expression, startIndex = startOffset).also { expressionStart -> + check(expressionStart in startOffset until endOffset) { + "Missing return expression $expression in $statement" + } + } + } + val returnExpressionEndOffset = returnExpressionStartOffset?.plus(requireNotNull(returnExpression).length) return CallsSourceTarget( targetId = "$functionName#$statement", @@ -90,6 +184,9 @@ class CallsSourceReplayTest { endOffset = endOffset, start = sourcePositionAt(source = source, offset = startOffset), end = sourcePositionAt(source = source, offset = endOffset), + mode = mode, + returnExpressionStartOffset = returnExpressionStartOffset, + returnExpressionEndOffset = returnExpressionEndOffset, ) } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt new file mode 100644 index 000000000..82004909c --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetryTest.kt @@ -0,0 +1,85 @@ +package org.usvm.ts.calls + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.machine.call.TsResidualCallPolicy +import org.usvm.machine.call.TsUnknownCallDecision +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallOutcome +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CallsUnknownCallTelemetryTest { + @Test + fun `sink converts unknown call events into ordered serializable cell records`() { + val source = resourcePath("/calls/SourceTargetReplayFixture.ts") + val file = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val method = file.allClasses.flatMap { cls -> cls.methods } + .single { candidate -> candidate.name == "completesReturnExpression" } + val callSite = method.cfg.stmts.first { statement -> statement.location.origin != null } + val cell = CallsExperimentCellIdentity( + experimentId = "experiment", + projectId = "project", + revision = "revision", + development = true, + functionId = "function", + targetId = "target", + siteId = "site", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + profile = CallsExperimentProfile.FROZEN_STOP, + seed = 17L, + ) + val records = mutableListOf() + val sink = callsUnknownCallEventSink(cell = cell, appendAndFlush = records::add) + + sink( + TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + decision = TsUnknownCallDecision.ModelApplied(modelId = "ts.array.isArray"), + ) + ) + sink( + TsUnknownCallEvent( + callSite = callSite, + callee = method.signature, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + decision = TsUnknownCallDecision.ResidualFallback(TsResidualCallPolicy.STOP_PATH), + ) + ) + + assertEquals(listOf(1, 2), records.map { record -> record.eventIndex }) + val modeled = records.first() + assertEquals(cell, modeled.cell) + assertEquals(source.fileName.toString(), modeled.callSite.sourcePath) + assertNotNull(modeled.callSite.start) + assertEquals("PARTIAL_APPROXIMATION", modeled.failureReason) + assertEquals(CallsUnknownCallDecisionKind.MODEL_APPLIED, modeled.decision) + assertEquals(TsUnknownCallOutcome.MODEL_APPLIED.name, modeled.outcome) + assertEquals("ts.array.isArray", modeled.modelId) + assertNull(modeled.residualPolicy) + + val residual = records.last() + assertEquals(CallsUnknownCallDecisionKind.RESIDUAL_FALLBACK, residual.decision) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED.name, residual.outcome) + assertNull(residual.modelId) + assertEquals(TsResidualCallPolicy.STOP_PATH.name, residual.residualPolicy) + + val encoded = CallsExperimentJson.json.encodeToString(modeled) + val decoded = CallsExperimentJson.json.decodeFromString(encoded) + assertEquals(modeled, assertIs(decoded)) + } + + private fun resourcePath(name: String): Path { + val resource = checkNotNull(javaClass.getResource(name)) { "Missing test resource: $name" } + return Path.of(resource.toURI()) + } +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt index b8923279e..6bf1814d2 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -1,9 +1,27 @@ package org.usvm.ts.calls +import org.junit.jupiter.api.io.TempDir +import org.usvm.machine.call.TsUnknownCallEvent +import org.usvm.ts.pbt.model.ArrayDomain +import org.usvm.ts.pbt.model.JsConcreteValue +import org.usvm.ts.pbt.model.NumberDomain +import org.usvm.ts.pbt.model.PropertyInput +import org.usvm.ts.pbt.model.StringDomain +import org.usvm.ts.pbt.model.TypeScriptEntryPoint +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class CurrentTsCallsSymbolicEngineTest { + @TempDir + lateinit var directory: Path + @Test fun `bundled frontend accepts only the revision baked into the running build`() { val engine = CurrentTsCallsSymbolicEngine( @@ -30,4 +48,401 @@ class CurrentTsCallsSymbolicEngineTest { engine.verifyNativeFrontendOnce(expectedRevision = "bundled:published-jacodb") } } + + @Test + fun `extracts nonempty generic number array containing zero and replays source`() { + val fixture = fixture( + source = """ + export function includesZero(values: T[], value: T): boolean { + if (value === 0 && values.indexOf(value) >= 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "includesZero", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + PropertyInput(name = "value", domain = NumberDomain()), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.indexOf", "ts.math.floor")) + + val inputs = assertNotNull(result.inputs, result.toString()) + val values = assertIs(inputs[0]).elements + val searchValue = assertIs(inputs[1]).toDouble() + assertTrue(values.isNotEmpty()) + assertTrue(searchValue == 0.0) + assertTrue(values.any { element -> assertIs(element).toDouble() == 0.0 }) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts nonempty number array with absent NaN and replays source`() { + val fixture = fixture( + source = """ + export function excludesNaN(values: number[], value: number): boolean { + if (values.length > 0 && Number.isNaN(value) && values.indexOf(value) < 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "excludesNaN", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + PropertyInput(name = "value", domain = NumberDomain()), + ), + targetStatement = "return true;", + ) + + val unknownCalls = mutableListOf() + val result = fixture.search( + modelIds = setOf("ts.array.indexOf", "ts.math.floor", "ts.number.isNaN"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, result.toString()) + assertTrue(assertIs(inputs[0]).elements.isNotEmpty()) + assertTrue(assertIs(inputs[1]).toDouble().isNaN()) + assertTrue(unknownCalls.isNotEmpty()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts constrained UTF-16 string and replays source`() { + val fixture = fixture( + source = """ + export function isGrinningFace(value: string): boolean { + if (value.length === 2 && value.charCodeAt(0) === 0xD83D && value.charCodeAt(1) === 0xDE00) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "isGrinningFace", + inputs = listOf( + PropertyInput(name = "value", domain = StringDomain(minLength = 1, maxLength = 4)), + ), + targetStatement = "return true;", + ) + + val result = fixture.search( + modelIds = setOf( + "ts.string.charCodeAt", + "ts.string.primitive.codeUnitAt", + "ts.string.primitive.length", + ), + ) + + val inputs = assertNotNull(result.inputs, result.toString()) + val value = assertIs(inputs.single()).value + assertEquals(2, value.length) + assertEquals("😀", value) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `extracts nonempty string required by value equality and replays source`() { + val fixture = fixture( + source = """ + export function equalsA(value: string): boolean { + if (value === "a") { + return true; + } + return false; + } + """.trimIndent(), + exportName = "equalsA", + inputs = listOf( + PropertyInput(name = "value", domain = StringDomain(minLength = 1, maxLength = 4)), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = emptySet()) + + val inputs = assertNotNull(result.inputs, result.toString()) + assertEquals("a", assertIs(inputs.single()).value) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `completed return observes normal expression-bodied arrow completion`() { + val expression = "value > 0" + val fixture = fixture( + source = "export const isPositive = (value: number): boolean => $expression;", + exportName = "isPositive", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = expression, + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = expression, + ) + + val preflight = fixture.preflight() + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + val result = fixture.search(modelIds = emptySet()) + + val inputs = assertNotNull(result.inputs, result.toString()) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `completed return rejects a partial expression target without an origin`() { + val fixture = fixture( + source = "export const isPositive = (value: number): boolean => value > 0;", + exportName = "isPositive", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "0", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "0", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `completed return rejects the first line of a multiline arrow expression`() { + val fixture = fixture( + source = """ + export const increment = (value: number): number => value + + 1; + """.trimIndent(), + exportName = "increment", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "value\n", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value\n", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `preflight rejects source coordinates that do not match offsets`() { + val fixture = fixture( + source = "export function identity(value: number): number { return value; }", + exportName = "identity", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return value;", + ) + val malformedTarget = fixture.target.copy( + start = fixture.target.start.copy(column = fixture.target.start.column + 1), + ) + + val preflight = fixture.copy(target = malformedTarget).preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNMAPPED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, preflight.reasonCode) + } + + @Test + fun `preflight rejects return expression offsets outside the target`() { + val fixture = fixture( + source = "export function identity(value: number): number { return value; }", + exportName = "identity", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return value;", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + val malformedTarget = fixture.target.copy( + returnExpressionStartOffset = fixture.target.endOffset, + returnExpressionEndOffset = fixture.target.endOffset + 1, + ) + + val preflight = fixture.copy(target = malformedTarget).preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNMAPPED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNMAPPED, preflight.reasonCode) + } + + @Test + fun `rejects arbitrary lexical runtime capture before search`() { + val fixture = fixture( + source = """ + let capturesThreshold: (value: number) => boolean; + { + const threshold = 3; + capturesThreshold = (value: number): boolean => value > threshold; + } + export { capturesThreshold }; + """.trimIndent(), + exportName = "capturesThreshold", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "value > threshold", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.LEXICAL_CAPTURE_UNSUPPORTED, result.reasonCode) + assertTrue(result.diagnostic.orEmpty().contains("threshold")) + } + + @Test + fun `extracts original array before mutating shift and replays source`() { + val fixture = fixture( + source = """ + export function removesSeventeen(values: number[]): boolean { + const removed = values.shift(); + if (removed === 17) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "removesSeventeen", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), minLength = 1, maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.search(modelIds = setOf("ts.array.shift")) + + val inputs = assertNotNull(result.inputs, result.toString()) + val values = assertIs(inputs.single()).elements + assertTrue(values.isNotEmpty()) + assertEquals(17.0, assertIs(values.first()).toDouble()) + fixture.assertReplayConfirmed(inputs) + } + + private fun fixture( + source: String, + exportName: String, + inputs: List, + targetStatement: String, + targetMode: CallsSourceTargetMode = CallsSourceTargetMode.ENTRY, + returnExpression: String? = null, + ): SymbolicFixture { + val sourceRoot = Files.createDirectory(directory.resolve(exportName)) + val sourceFile = sourceRoot.resolve("Fixture.ts") + Files.writeString(sourceFile, source) + runGit(sourceRoot, "init") + runGit(sourceRoot, "config", "user.name", "USVM Tests") + runGit(sourceRoot, "config", "user.email", "usvm@example.test") + runGit(sourceRoot, "add", "Fixture.ts") + runGit(sourceRoot, "commit", "-m", "fixture") + val revision = runGit(sourceRoot, "rev-parse", "HEAD").trim() + val targetStart = source.indexOf(targetStatement) + check(targetStart >= 0) { "Missing target statement: $targetStatement" } + val targetEnd = targetStart + targetStatement.length + val returnExpressionStart = returnExpression?.let { expression -> + source.indexOf(expression, startIndex = targetStart).takeIf { offset -> + offset in targetStart until targetEnd + } + ?: error("Missing return expression inside target: $expression") + } + val target = CallsSourceTarget( + targetId = "$exportName#$targetStatement", + siteId = "$exportName:$targetStart:$targetEnd", + sourcePath = "Fixture.ts", + startOffset = targetStart, + endOffset = targetEnd, + start = sourcePositionAt(source = source, offset = targetStart), + end = sourcePositionAt(source = source, offset = targetEnd), + mode = targetMode, + returnExpressionStartOffset = returnExpressionStart, + returnExpressionEndOffset = returnExpressionStart?.plus(requireNotNull(returnExpression).length), + ) + val function = CallsFunctionCase( + functionId = exportName, + sourceFile = "Fixture.ts", + entryPoint = TypeScriptEntryPoint(module = "Fixture.ts", exportName = exportName), + inputs = inputs, + targets = listOf(target), + ) + val project = CallsProjectCase( + projectId = exportName, + revision = revision, + sourceRoot = ".", + development = true, + functions = listOf(function), + ) + + return SymbolicFixture( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + ) + } + + private fun runGit(directory: Path, vararg arguments: String): String { + val process = ProcessBuilder(listOf("git", "-C", directory.toString()) + arguments) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + check(process.waitFor() == 0) { "Git ${arguments.joinToString()} failed: $output" } + return output + } + + private data class SymbolicFixture( + val sourceRoot: Path, + val project: CallsProjectCase, + val function: CallsFunctionCase, + val target: CallsSourceTarget, + ) { + private val engine = CurrentTsCallsSymbolicEngine( + environment = emptyMap()::get, + bundledNativeFrontendRevision = "bundled:test", + ) + + fun preflight(): CallsSymbolicPreflightResult = engine.preflight( + CallsSymbolicPreflightRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + expectedNativeFrontendRevision = "bundled:test", + ) + ) + + fun search( + modelIds: Set, + unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + ): CallsSymbolicSearchResult = engine.search( + CallsSymbolicSearchRequest( + sourceRoot = sourceRoot, + project = project, + function = function, + target = target, + profile = CallsExperimentProfile.FROZEN_STOP, + frozenModelIds = modelIds, + expectedNativeFrontendRevision = "bundled:test", + seed = 0, + budget = 10.seconds, + unknownCallEventSink = unknownCallEventSink, + ) + ) + + fun assertReplayConfirmed(inputs: List) { + val replay = OriginalTypeScriptTargetReplayer().replay( + sourceRoots = listOf(sourceRoot), + entryPoint = function.entryPoint, + inputs = inputs, + target = target, + timeoutMillis = 10_000L, + ) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + } + } } diff --git a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index dd20e17a4..3a6fd54e3 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -13,3 +13,27 @@ const importedTargetValue = importOnlyTarget(); export function skipsImportOnlyTarget(): number { return importedTargetValue; } + +export function completesReturnExpression(value: string): string { + return /* before expression */ value.trim() /* after expression */ +} + +function throwFromExpression(): never { + throw new Error('expression failed'); +} + +export function expressionThrowsBeforeReturnCompletes(): string { + return throwFromExpression(); +} + +export function finallyThrowsAfterReturnExpression(): string { + try { + return 'value'.trim(); + } finally { + throw new Error('finally failed'); + } +} + +export function completesBareReturn(): void { + return /* no expression */ +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts new file mode 100644 index 000000000..1f30f1d93 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +function fail(message: string): never { + process.stderr.write(`${message}\n`) + process.exit(2) +} + +const [sourcePath, exportName, startText, endText] = process.argv.slice(2) +if (sourcePath === undefined || exportName === undefined || startText === undefined || endText === undefined) { + fail('Expected source path, export name, start offset, and end offset') +} + +const startOffset = Number(startText) +const endOffset = Number(endText) +if (!Number.isSafeInteger(startOffset) || !Number.isSafeInteger(endOffset)) { + fail('Source offsets must be safe integers') +} + +const source = readFileSync(sourcePath, 'utf8') +const diagnostics = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.Latest }, + fileName: sourcePath, + reportDiagnostics: true, +}).diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? [] +if (diagnostics.length > 0) { + fail(`Cannot parse TypeScript source: ${ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')}`) +} + +const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + +const bodies: ts.ConciseBody[] = [] +for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + const exported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true + if (!exported) continue + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== exportName) continue + if (!declaration.initializer || !ts.isArrowFunction(declaration.initializer)) continue + if (ts.isBlock(declaration.initializer.body)) continue + + bodies.push(declaration.initializer.body) + } +} + +const matchingBodies = bodies.filter((body) => + body.getStart(sourceFile, false) === startOffset && body.getEnd() === endOffset, +) +process.stdout.write(matchingBodies.length === 1 ? 'true' : 'false') diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt index c19329500..b5f63d8a6 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckRuntime.kt @@ -12,6 +12,8 @@ internal object FastCheckRuntime { fun processSupervisorEntryPoint(): Path = locateEntryPoint(PROCESS_SUPERVISOR) + fun sourceInspectorEntryPoint(): Path = locateEntryPoint(SOURCE_INSPECTOR_CLI) + private fun locateEntryPoint(fileName: String): Path { val candidates = runtimeDirectories().map { runtimeDirectory -> runtimeDirectory.resolve(ENTRY_POINT_DIRECTORY).resolve(fileName) @@ -49,5 +51,6 @@ internal object FastCheckRuntime { private const val EXECUTION_CLI = "execution-cli.js" private const val PROJECTION_CLI = "projection-cli.js" private const val PROCESS_SUPERVISOR = "process-supervisor.js" + private const val SOURCE_INSPECTOR_CLI = "source-inspector-cli.js" private const val INSTALLED_RUNTIME_DIRECTORY = "fast-check-adapter" } diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt new file mode 100644 index 000000000..d6dbd1696 --- /dev/null +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt @@ -0,0 +1,40 @@ +package org.usvm.ts.pbt.fastcheck + +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +/** Exact TypeScript-AST validation used when EtsIR omits an expression-bodied arrow's source origin. */ +object TypeScriptSourceInspector { + fun isExportedExpressionArrowBody( + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + nodeExecutable: String = "node", + ): Boolean { + val process = ProcessBuilder( + nodeExecutable, + FastCheckRuntime.sourceInspectorEntryPoint().toString(), + source.toString(), + exportName, + startOffset.toString(), + endOffset.toString(), + ).redirectErrorStream(true).start() + if (!process.waitFor(SOURCE_INSPECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + error("TypeScript source inspection timed out") + } + + val output = process.inputStream.bufferedReader().use { reader -> reader.readText() }.trim() + require(process.exitValue() == 0) { + "TypeScript source inspection failed with exit ${process.exitValue()}: $output" + } + return when (output) { + "true" -> true + "false" -> false + else -> error("TypeScript source inspection returned an invalid response: $output") + } + } + + private const val SOURCE_INSPECTION_TIMEOUT_SECONDS: Long = 10 +} diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt index 12cc28be1..bf0cfa01e 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsEntryPointResolver.kt @@ -7,8 +7,10 @@ import org.jacodb.ets.model.EtsExportInfo import org.jacodb.ets.model.EtsExportType import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsMethodParameter import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef @@ -49,7 +51,8 @@ internal class EtsEntryPointResolver( val hasAmbiguousResolution = hasAmbiguousCandidateResolution || hasAmbiguousSourceResolution val entryPointName = "${entryPoint.module}#${entryPoint.exportName}" - if (methods.any { method -> method.parameters.size != manifest.inputs.size }) { + val methodsWithBindings = methods.map { method -> method to method.bindingParameters() } + if (methodsWithBindings.any { (_, parameters) -> parameters.inputs.size != manifest.inputs.size }) { val diagnostic = EtsMappingDiagnostic( code = PbtDiagnosticCode.MAPPING_ENTRY_POINT_BINDINGS_UNSUPPORTED, message = "Property inputs do not match EtsIR parameters for ${entryPoint.exportName}", @@ -63,10 +66,13 @@ internal class EtsEntryPointResolver( ) } - val targets = methods.map { method -> + val targets = methodsWithBindings.map { (method, parameters) -> EtsEntryPointTarget( method = method, - bindings = method.bindingsFor(manifest), + bindings = method.bindingsFor( + manifest = manifest, + parameters = parameters, + ), ) } @@ -231,16 +237,32 @@ internal class EtsEntryPointResolver( return modulePaths.any(filePaths::contains) } - private fun EtsMethod.bindingsFor(manifest: PropertyManifest): EtsEntryPointBindings { + private fun EtsMethod.bindingParameters(): EtsBindingParameters { + // The frontend lifts arrow-function captures into one leading lexical-environment parameter. + // It is not a source argument; the interpreter recognizes the same parameter by its semantic type. + val hiddenClosure = parameters.firstOrNull()?.takeIf { parameter -> + parameter.type is EtsLexicalEnvType + } + + return EtsBindingParameters( + lexicalEnvironment = hiddenClosure, + inputs = if (hiddenClosure == null) parameters else parameters.drop(1), + ) + } + + private fun EtsMethod.bindingsFor( + manifest: PropertyManifest, + parameters: EtsBindingParameters, + ): EtsEntryPointBindings { val receiverType = EtsClassType( signature = signature.enclosingClass, typeParameters = requireNotNull(enclosingClass).typeParameters, ) - val inputBindings = manifest.inputs.zip(parameters).mapIndexed { index, (input, parameter) -> + val inputBindings = manifest.inputs.zip(parameters.inputs).map { (input, parameter) -> EtsInputBinding( propertyInputName = input.name, parameter = parameter, - stackSlot = index + RECEIVER_STACK_SLOTS, + stackSlot = parameter.index + RECEIVER_STACK_SLOTS, ) } @@ -251,10 +273,21 @@ internal class EtsEntryPointResolver( ), inputs = inputBindings, result = EtsResultBinding(type = returnType), + lexicalEnvironment = parameters.lexicalEnvironment?.let { parameter -> + EtsLexicalEnvironmentBinding( + parameter = parameter, + stackSlot = parameter.index + RECEIVER_STACK_SLOTS, + ) + }, ) } } +private data class EtsBindingParameters( + val lexicalEnvironment: EtsMethodParameter?, + val inputs: List, +) + private data class ExportResolutionStep( val file: EtsFile, val exportName: String, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt index 5d0b3f931..7fc3a5ca9 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/mapping/EtsMappingModel.kt @@ -96,6 +96,12 @@ data class EtsReceiverBinding( val type: EtsType, ) +/** Preserves the frontend's hidden lexical-environment parameter separately from source arguments. */ +data class EtsLexicalEnvironmentBinding( + val parameter: EtsMethodParameter, + val stackSlot: Int, +) + /** Connects one ordered property input to the corresponding EtsIR parameter and stack slot. */ data class EtsInputBinding( val propertyInputName: String, @@ -113,6 +119,7 @@ data class EtsEntryPointBindings( val receiver: EtsReceiverBinding, val inputs: List, val result: EtsResultBinding, + val lexicalEnvironment: EtsLexicalEnvironmentBinding? = null, ) /** Resolved EtsIR method and its property-facing symbolic bindings. */ diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt index 401ce47be..aaeb255b8 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFunctionType import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsLexicalEnvType import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME @@ -18,9 +19,65 @@ import org.usvm.ts.pbt.model.TypeScriptEntryPoint import org.usvm.ts.pbt.testResourcePath import java.nio.file.Path import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class PropertyEtsExportResolutionTest { + @Test + fun `exported arrow binds source input after its hidden builtin capture`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "usesCapturedBuiltins"), + ) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val target = artifact.predicate.targets.single() + val lexicalEnvironment = assertNotNull(target.bindings.lexicalEnvironment) + val hiddenCapture = lexicalEnvironment.parameter + val captureType = hiddenCapture.type as EtsLexicalEnvType + val input = target.bindings.inputs.single() + assertEquals(0, hiddenCapture.index) + assertEquals(1, lexicalEnvironment.stackSlot) + assertEquals(listOf("Number", "Error"), captureType.closures.map { closure -> closure.name }) + assertEquals("value", input.parameter.name) + assertEquals(1, input.parameter.index) + assertEquals(2, input.stackSlot) + } + + @Test + fun `arbitrary captured runtime value remains explicit in lexical environment binding`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "capturesModuleValue"), + ) + + assertEquals(EtsMappingStatus.EXACT, artifact.predicate.status) + val bindings = artifact.predicate.targets.single().bindings + val lexicalEnvironment = assertNotNull(bindings.lexicalEnvironment) + val captureType = lexicalEnvironment.parameter.type as EtsLexicalEnvType + assertEquals(listOf("threshold"), captureType.closures.map { closure -> closure.name }) + assertEquals(1, lexicalEnvironment.stackSlot) + assertEquals(2, bindings.inputs.single().stackSlot) + } + + @Test + fun `hidden builtin capture does not conceal unmatched source parameters`() { + val source = testResourcePath("/mapping/exports/CapturedBuiltinArrow.ts") + val mapper = mapper(source) + + val artifact = mapper.map( + manifest(module = source.fileName.toString(), exportName = "capturedBuiltinsWithTwoInputs"), + ) + + assertEquals(EtsMappingStatus.UNSUPPORTED, artifact.predicate.status) + assertEquals(emptyList(), artifact.predicate.targets) + assertEquals("mapping.entry-point.bindings.unsupported", artifact.predicate.diagnostics.single().code) + } + @Test fun `named default declaration resolves only through the default export name`() { val source = testResourcePath("/mapping/exports/NamedDefaultDeclaration.ts") diff --git a/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts b/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts new file mode 100644 index 000000000..026cd0653 --- /dev/null +++ b/usvm-ts-pbt/src/test/resources/mapping/exports/CapturedBuiltinArrow.ts @@ -0,0 +1,19 @@ +export const usesCapturedBuiltins = (value: number): boolean => { + if (!Number.isInteger(value)) { + throw new Error('expected an integer') + } + + return value % 2 === 0 +} + +export const capturedBuiltinsWithTwoInputs = (left: number, right: number): boolean => + Number.isFinite(left) && left === right + +let capturesModuleValue: (value: number) => boolean + +{ + const threshold = 3 + capturesModuleValue = (value: number): boolean => value > threshold +} + +export { capturesModuleValue } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt index d6e5f7b59..414ce7f5f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsMachine.kt @@ -8,6 +8,7 @@ import org.usvm.CoverageZone import org.usvm.StateCollectionStrategy import org.usvm.UMachine import org.usvm.UMachineOptions +import org.usvm.USort import org.usvm.api.targets.TsTarget import org.usvm.machine.call.TsBuiltInUnknownCallModels import org.usvm.machine.call.TsModelUnknownCallDispatcher @@ -59,6 +60,8 @@ class TsMachine( observer: TsInterpreterObserver? = null, unknownCallDispatcher: TsUnknownCallDispatcher? = null, unknownCallModels: TsUnknownCallModelCatalog? = null, + private val initialStateConfigurator: (TsState) -> Unit = {}, + private val initialParameterSortOverride: (TsContext, Int) -> USort? = { _, _ -> null }, ) : UMachine() { private val resolvedUnknownCallModels = when { unknownCallDispatcher != null -> null @@ -112,7 +115,14 @@ class TsMachine( targets: List = emptyList(), ): TsAnalysisResult { val initialStates = mutableMapOf() - methods.forEach { initialStates[it] = interpreter.getInitialState(it, targets) } + methods.forEach { method -> + initialStates[method] = interpreter.getInitialState( + method = method, + targets = targets, + configure = initialStateConfigurator, + parameterSortOverride = { stackSlot -> initialParameterSortOverride(ctx, stackSlot) }, + ) + } val methodsToTrackCoverage = when (options.coverageZone) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index 825fbe817..f896269a5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -2,6 +2,7 @@ package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact @@ -13,6 +14,7 @@ import org.usvm.machine.call.TsUnknownCallModel import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact import org.usvm.util.arrayStorageType +import org.usvm.util.isUnmodifiedDenseInputArray /** Built-in Array algorithms implemented by ordinary TypeScript bodies. */ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { @@ -48,22 +50,24 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { falseExpr } else { val elementSort = typeToSort(receiverType.elementType) + val hasNoMissingSlots = array is UConcreteHeapRef && + state.isUnmodifiedDenseInputArray(array, receiverType) val excludesMissingSlot = when { searchElement == mkUndefinedValue() && ( call.callee.name in setOf("indexOf", "lastIndexOf") || elementSort == fp64Sort || elementSort == boolSort - ) -> falseExpr + ) -> mkBool(hasNoMissingSlots) elementSort == fp64Sort && searchElement?.sort == fp64Sort -> { val searchNumber = searchElement.asExpr(fp64Sort) val zero = mkFp64(0.0) - mkNot(mkFpEqualExpr(searchNumber, zero)) + mkOr(mkBool(hasNoMissingSlots), mkNot(mkFpEqualExpr(searchNumber, zero))) } elementSort == boolSort && searchElement?.sort == boolSort -> { - searchElement.asExpr(boolSort) + mkOr(mkBool(hasNoMissingSlots), searchElement.asExpr(boolSort)) } else -> trueExpr diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt new file mode 100644 index 000000000..bfc81fb30 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt @@ -0,0 +1,91 @@ +package org.usvm.machine.call.intrinsic + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.machine.call.TsUnknownCall +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.state.TsState + +/** Exact `Array.isArray` semantics for the genuine global `Array` built-in. */ +internal object TsArrayIsArrayIntrinsicModel : TsBuiltInUnknownCallModel { + const val MODEL_ID: String = "ts.array.isArray" + + private val anyArrayType = EtsArrayType(elementType = EtsUnknownType, dimensions = 1) + + override val id: String = MODEL_ID + override val target = TsUnknownCallTarget( + methodName = "isArray", + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (!call.hasGlobalArrayOwner()) { + return null + } + + val value = when { + call.arguments.isEmpty() -> return state.normalExecution(state.ctx.falseExpr) + else -> call.arguments.first().resolved ?: return null + } + val result = state.isArray(value) + + return state.normalExecution(result) + } + + private fun TsUnknownCall.hasGlobalArrayOwner(): Boolean { + val owner = receiver?.source as? EtsLocal ?: return false + val ownerType = owner.type as? EtsFunctionType ?: return false + val signatureFile = ownerType.signature.enclosingClass.file + + return owner.name == "Array" && + ownerType.signature.returnType is EtsArrayType && + signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT + } + + private fun TsState.isArray(value: UExpr<*>) = with(ctx) { + when { + value.isFakeObject() -> { + val fakeType = value.getFakeType(memory) + val reference = value.extractRef(memory) + + mkAnd( + fakeType.refTypeExpr, + isNonNullArrayReference(reference), + ) + } + + value.sort == addressSort -> isNonNullArrayReference(value.asExpr(addressSort)) + else -> falseExpr + } + } + + private fun TsState.isNonNullArrayReference(reference: UHeapRef): UBoolExpr = with(ctx) { + mkAnd( + mkNot(mkEq(reference, mkUndefinedValue())), + mkNot(mkEq(reference, mkTsNullValue())), + memory.types.evalIsSubtype(reference, anyArrayType), + ) + } + + private fun TsState.normalExecution(result: UExpr<*>): TsUnknownCallModelExecution = with(ctx) { + TsUnknownCallModelExecution( + successors = listOf( + TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { result }, + ) + ) + ) + } + + private const val UNKNOWN_SIGNATURE_COMPONENT: String = "%unk" +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index b5e3643ca..144f36268 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -23,9 +23,11 @@ import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact import org.usvm.machine.state.TsState import org.usvm.sizeSort +import org.usvm.util.copyStringRange import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkFieldLValue +import org.usvm.util.stringFromCodeUnit /** Built-in String algorithms implemented by ordinary TypeScript bodies. */ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { @@ -98,11 +100,48 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val sliceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val receiver = inputs.firstOrNull()?.takeIf { it.sort == state.ctx.addressSort } + ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > 2) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + fun numericOrDefault(index: Int, default: UExpr<*>): UExpr<*> { + val value = arguments.getOrNull(index) ?: return default + return when { + value == state.ctx.mkUndefinedValue() -> default + value.sort == state.ctx.fp64Sort -> value + else -> return default + } + } + + val start = numericOrDefault(index = 0, default = state.ctx.mkFp64(0.0)) + val end = numericOrDefault( + index = 1, + default = state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort), + ) + if (arguments.any { value -> value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort }) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + listOf(receiver, start, end) + } + + private val noArgumentsAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.isNotEmpty()) { + null + } else { + call.resolvedInstanceInputs() + } + } + private val receiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> with(state.ctx) { val receiver = inputs.firstOrNull() - // Symbolic String parameters do not initialize the backing character array yet. - if (receiver !is UConcreteHeapRef || getStringConstantValue(receiver) == null) { + if (receiver !is UConcreteHeapRef || receiver.hasFakeValueBranch()) { falseExpr } else { state.memory.types.evalTypeEquals(receiver, EtsStringType) @@ -114,9 +153,8 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { with(state.ctx) { val receiver = inputs.getOrNull(0) val searchString = inputs.getOrNull(1) - val receiverIsConstant = receiver is UConcreteHeapRef && getStringConstantValue(receiver) != null - val searchStringIsConstant = - searchString is UConcreteHeapRef && getStringConstantValue(searchString) != null + val receiverIsConstant = receiver is UConcreteHeapRef && !receiver.hasFakeValueBranch() + val searchStringIsConstant = searchString is UConcreteHeapRef && !searchString.hasFakeValueBranch() if (!receiverIsConstant || !searchStringIsConstant) { falseExpr @@ -129,6 +167,43 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val asciiReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiverGuard = receiverDomain.evaluate(state, call, inputs) + val receiver = inputs.firstOrNull() as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val characters = state.memory.read(mkFieldLValue(addressSort, receiver, "value")) + .asExpr(addressSort) + val length = state.memory.read(mkArrayLengthLValue(characters, characterArrayType)) + val characterGuards = (0 until MAX_ASCII_CASE_LENGTH).map { index -> + val symbolicIndex = mkBv(index) + val codeUnit = state.memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = symbolicIndex, + type = characterArrayType, + ) + ) + val isOutsideLength = mkBvSignedGreaterOrEqualExpr(symbolicIndex, length) + val asciiMax = mkBv(ASCII_MAX_CODE_UNIT, bv16Sort) + val isAscii = mkBvUnsignedLessOrEqualExpr(codeUnit, asciiMax) + mkOr( + isOutsideLength, + isAscii, + ) + } + + val maximumLength = mkBv(MAX_ASCII_CASE_LENGTH) + val withinLengthLimit = mkBvSignedLessOrEqualExpr(length, maximumLength) + mkAnd( + receiverGuard, + withinLengthLimit, + *characterGuards.toTypedArray(), + ) + } + } + private val receiverAndConcreteIndexDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> if (inputs.getOrNull(1) !is KFp64Value) { state.ctx.falseExpr @@ -181,6 +256,24 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { inputAdapter = optionalEndPositionAdapter, domainGuard = receiverAndSearchDomain, ), + sourceModel( + id = "ts.string.slice", + methodName = "slice", + inputAdapter = sliceAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.toUpperCase", + methodName = "toUpperCase", + inputAdapter = noArgumentsAdapter, + domainGuard = asciiReceiverDomain, + ), + sourceModel( + id = "ts.string.toLowerCase", + methodName = "toLowerCase", + inputAdapter = noArgumentsAdapter, + domainGuard = asciiReceiverDomain, + ), primitiveModel( methodName = "length", arity = 1, @@ -196,6 +289,11 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { arity = 1, implementation = ::stringFromCodeUnit, ), + primitiveModel( + methodName = "copyRange", + arity = 3, + implementation = ::stringCopyRange, + ), ) } @@ -218,9 +316,12 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { add(MATH_FLOOR_MODEL_ID) add(PRIMITIVE_LENGTH_ID) add(PRIMITIVE_CODE_UNIT_AT_ID) - if (methodName == "charAt") { + if (methodName in setOf("charAt", "toUpperCase", "toLowerCase")) { add(PRIMITIVE_FROM_CODE_UNIT_ID) } + if (methodName == "slice") { + add(PRIMITIVE_COPY_RANGE_ID) + } }, ) @@ -314,16 +415,58 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { state: TsState, inputs: List>, ): TsUnknownCallModelExecution? = with(state.ctx) { - val code = inputs.singleOrNull() as? KFp64Value ?: return null + val code = inputs.singleOrNull()?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null val successor = TsUnknownCallModelSuccessor( guard = trueExpr, completion = TsUnknownCallModelCompletion.Normal { - mkInitializedStringConstant(code.value.toInt().toChar().toString()) + if (code is KFp64Value) { + mkInitializedStringConstant(code.value.toInt().toChar().toString()) + } else { + stringFromCodeUnit(code) + } + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + ) + } + + private fun stringCopyRange( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0)?.takeIf { it.sort == addressSort }?.asExpr(addressSort) ?: return null + val start = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val end = inputs.getOrNull(2)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + if (receiver.hasFakeValueBranch()) { + return null + } + + val from = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = start, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val to = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = end, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val length = mkBvSubExpr(to, from) + val receiverIsString = state.memory.types.evalTypeEquals(receiver, EtsStringType) + val successor = TsUnknownCallModelSuccessor( + guard = receiverIsString, + completion = TsUnknownCallModelCompletion.Normal { + copyStringRange(receiver = receiver, from = from, length = length) }, ) TsUnknownCallModelExecution( successors = listOf(successor), + residualGuard = receiverIsString.takeUnless { it == trueExpr }?.let(::mkNot), ) } @@ -375,5 +518,8 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private const val PRIMITIVE_LENGTH_ID = "ts.string.primitive.length" private const val PRIMITIVE_CODE_UNIT_AT_ID = "ts.string.primitive.codeUnitAt" private const val PRIMITIVE_FROM_CODE_UNIT_ID = "ts.string.primitive.fromCodeUnit" + private const val PRIMITIVE_COPY_RANGE_ID = "ts.string.primitive.copyRange" private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" + private const val MAX_ASCII_CASE_LENGTH = 16 + private const val ASCII_MAX_CODE_UNIT = 0x7f } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 3728c3eef..6c93effe7 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -47,6 +47,7 @@ import org.usvm.util.mkArrayLengthLValue import org.usvm.util.resolveEtsMethods private val logger = KotlinLogging.logger {} +private val legacyArrayMethods = setOf("concat", "fill", "join", "push", "reverse", "slice", "unshift") internal fun TsExprResolver.tryApproximateGlobalInstanceCall( expr: EtsInstanceCallExpr, @@ -134,6 +135,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( // Mock `.toString()` method calls if (expr.callee.name == "toString") { + if (unknownCallDispatcher is TsUnknownCallModelDispatcher) { + dispatchLegacyInstanceCall(stmt) + return TsExprApproximationResult.ResolveFailure + } + if (expr.args.isNotEmpty()) { logger.warn { "toString() should have no arguments, but got ${expr.args.size}" } } @@ -158,6 +164,11 @@ internal fun TsExprResolver.tryApproximateInstanceCall( .takeIf { it !is TsUnresolvedSort } ?: addressSort + if (expr.callee.name in legacyArrayMethods && unknownCallDispatcher is TsUnknownCallModelDispatcher) { + dispatchArrayModel(stmt) + return TsExprApproximationResult.ResolveFailure + } + // Handle 'Array.push()' method calls if (expr.callee.name == "push") { return from(handleArrayPush(expr, instanceType, elementSort, array)) @@ -221,7 +232,10 @@ internal fun TsExprResolver.tryApproximateInstanceCall( "includes", "indexOf", "lastIndexOf", + "slice", "startsWith", + "toLowerCase", + "toUpperCase", ) if (instanceType is EtsStringType && expr.callee.name in modeledStringMethods) { val dispatcher = unknownCallDispatcher @@ -288,6 +302,17 @@ private fun TsExprResolver.dispatchArrayModel(stmt: TsVirtualMethodCallStmt) { ) } +private fun TsExprResolver.dispatchLegacyInstanceCall(stmt: TsVirtualMethodCallStmt) { + unknownCallDispatcher.dispatch( + scope = scope, + call = stmt.call, + callSite = stmt.returnSite, + failureReason = TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, + resolvedReceiver = stmt.instance, + resolvedArguments = stmt.args, + ) +} + private fun EtsMethodSignature.withEnclosingClassName(name: String): EtsMethodSignature = copy(enclosingClass = enclosingClass.copy(name = name)) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt index 0d913621e..1a22c0402 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ExprUtil.kt @@ -19,6 +19,11 @@ import org.usvm.machine.types.EtsFakeType import org.usvm.machine.types.ExprWithTypeConstraint import org.usvm.types.single import org.usvm.util.boolToFp +import org.usvm.util.refOrStringTruthy + +private fun TsState.refTruthyExpr(ref: UHeapRef): UBoolExpr = with(ctx) { + refOrStringTruthy(ref) +} fun TsContext.checkNotFake(expr: UExpr<*>) { require(!expr.isFakeObject()) { @@ -63,10 +68,7 @@ fun TsContext.mkTruthyExpr( val value = memory.read(getIntermediateRefLValue(expr.address)) conjuncts += ExprWithTypeConstraint( constraint = possibleType.refTypeExpr, - expr = mkAnd( - mkHeapRefEq(value, mkTsNullValue()).not(), - mkHeapRefEq(value, mkUndefinedValue()).not(), - ) + expr = refTruthyExpr(value), ) } @@ -89,10 +91,7 @@ fun TsContext.mkTruthyExpr( mkFpIsNaNExpr(expr.asExpr(fp64Sort)).not() ) - addressSort -> mkAnd( - mkHeapRefEq(expr.asExpr(addressSort), mkTsNullValue()).not(), - mkHeapRefEq(expr.asExpr(addressSort), mkUndefinedValue()).not(), - ) + addressSort -> refTruthyExpr(expr.asExpr(addressSort)) else -> TODO("Unsupported sort: ${expr.sort}") } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt index 86444e105..b20dc6427 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadLength.kt @@ -14,6 +14,7 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayLengthLValue +import org.usvm.util.stringLength // Handles reading the `length` property. fun TsContext.readLengthProperty( @@ -24,6 +25,18 @@ fun TsContext.readLengthProperty( ): UExpr<*>? { // Determine the array type. val storageType = scope.calcOnState { arrayStorageType(instance, instanceLocal.type) } + if (storageType is EtsStringType) { + val length = scope.calcOnState { stringLength(instance) } + ensureLengthBounds(scope, length, maxArraySize) ?: return null + + return mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.asExpr(sizeSort), + signed = true, + ) + } + val arrayType: EtsArrayType = when (val type = storageType) { is EtsArrayType -> type @@ -33,11 +46,6 @@ fun TsContext.readLengthProperty( EtsArrayType(EtsUnknownType, dimensions = 1) } - is EtsStringType -> { - // Strings are treated as arrays of characters (represented as strings). - EtsArrayType(EtsStringType, dimensions = 1) - } - else -> error("Expected EtsArrayType, EtsAnyType or EtsUnknownType, but got: $type") } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index 66184e3d1..c33e8543a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -89,6 +89,7 @@ import org.usvm.api.allocateConcreteRef import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArrayLength import org.usvm.api.makeSymbolicPrimitive +import org.usvm.api.typeStreamOf import org.usvm.dataflow.ts.infer.tryGetKnownType import org.usvm.dataflow.ts.util.type import org.usvm.isAllocatedConcreteHeapRef @@ -116,11 +117,13 @@ import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.sizeSort import org.usvm.util.EtsHierarchy import org.usvm.util.SymbolResolutionResult +import org.usvm.util.concatStrings import org.usvm.util.isResolved import org.usvm.util.mkFieldLValue import org.usvm.util.mkRegisterStackLValue import org.usvm.util.resolveEtsMethods import org.usvm.util.resolveImportInfo +import org.usvm.types.singleOrNull private val logger = KotlinLogging.logger {} @@ -580,15 +583,34 @@ class TsExprResolver( if (expr.type == EtsStringType) { return resolveAfterResolved(expr.left, expr.right) { lhs, rhs -> val lhsString = concreteStringValue(lhs) - ?: error("Symbolic string concatenation is not supported for left operand: $lhs") val rhsString = concreteStringValue(rhs) - ?: error("Symbolic string concatenation is not supported for right operand: $rhs") - ctx.mkStringConstant(lhsString + rhsString, scope) + if (lhsString != null && rhsString != null) { + return@resolveAfterResolved ctx.mkStringConstant(lhsString + rhsString, scope) + } + + val lhsRef = stringStorageRef(lhs) + ?: error("String concatenation is not supported for left operand: $lhs") + val rhsRef = stringStorageRef(rhs) + ?: error("String concatenation is not supported for right operand: $rhs") + scope.calcOnState { concatStrings(lhsRef, rhsRef) } } } return resolveBinaryOperator(TsBinaryOperator.Add, expr) } + private fun stringStorageRef(value: UExpr<*>): UHeapRef? = with(ctx) { + if (value.sort == addressSort) { + val ref = value.asExpr(addressSort) + val type = scope.calcOnState { memory.typeStreamOf(ref).singleOrNull() } + if (type is EtsStringType) { + return ref + } + } + + val concrete = concreteStringValue(value) ?: return null + mkStringConstant(concrete, scope) + } + private fun concreteStringValue(value: UExpr<*>): String? = with(ctx) { when { value == trueExpr -> "true" diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 423f5dde7..51546be72 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -34,6 +34,7 @@ import org.jacodb.ets.utils.callExpr import org.usvm.StepResult import org.usvm.StepScope import org.usvm.UExpr +import org.usvm.USort import org.usvm.UInterpreter import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArray @@ -712,7 +713,12 @@ class TsInterpreter( unknownCallDispatcher = unknownCallDispatcher, ) - fun getInitialState(method: EtsMethod, targets: List): TsState = with(ctx) { + fun getInitialState( + method: EtsMethod, + targets: List, + configure: (TsState) -> Unit = {}, + parameterSortOverride: (Int) -> USort? = { null }, + ): TsState = with(ctx) { val state = TsState( ctx = ctx, ownership = MutabilityOwnership(), @@ -739,6 +745,11 @@ class TsInterpreter( method.parameters.forEachIndexed { i, param -> val idx = i + 1 // +1 because 0 is reserved for `this` + val overriddenSort = parameterSortOverride(idx) + if (overriddenSort != null) { + state.saveSortForLocal(idx, overriddenSort) + return@forEachIndexed + } val ref by lazy { val lValue = mkRegisterStackLValue(addressSort, idx) @@ -802,6 +813,8 @@ class TsInterpreter( } } + configure(state) + val solver = solver() val model = solver.check(state.pathConstraints).ensureSat().model state.models = listOf(model) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index 9e4e1db47..f3981e409 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -18,6 +18,7 @@ import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.types.ExprWithTypeConstraint import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.util.boolToFp +import org.usvm.util.refOrStringValueEquals private val logger = KotlinLogging.logger {} @@ -392,7 +393,7 @@ sealed interface TsBinaryOperator { return mkOr( mkAnd(lhsIsUndefined, rhsIsNull), mkAnd(lhsIsNull, rhsIsUndefined), - mkHeapRefEq(lhs, rhs) + scope.calcOnState { refOrStringValueEquals(lhs, rhs) }, ) } @@ -557,7 +558,7 @@ sealed interface TsBinaryOperator { rhs: UHeapRef, scope: TsStepScope, ): UBoolExpr { - return mkHeapRefEq(lhs, rhs) + return scope.calcOnState { refOrStringValueEquals(lhs, rhs) } } override fun TsContext.resolveFakeObject( @@ -645,7 +646,7 @@ sealed interface TsBinaryOperator { val right = rhsValue.asExpr(addressSort) return mkAnd( typeConstraint, - mkHeapRefEq(left, right) + onRef(left, right, scope), ) } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 019257dd3..1773cfcf3 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt @@ -29,6 +29,7 @@ import org.usvm.machine.TsContext import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.TsFunction import org.usvm.memory.ULValue +import org.usvm.memory.UReadOnlyMemoryRegion import org.usvm.memory.UMemory import org.usvm.model.UModelBase import org.usvm.sizeSort @@ -81,6 +82,8 @@ class TsState( * for identical string values. */ var stringConstantAllocatedRefs: UPersistentHashMap = persistentHashMapOf(), + internal val stringMaxLengths: MutableMap = mutableMapOf(), + internal val denseInputArrays: MutableMap = mutableMapOf(), private val activeUnknownCallModels: MutableList> = mutableListOf(), ) : UState( ctx = ctx, @@ -273,6 +276,7 @@ class TsState( ref } stringConstantAllocatedRefs = updated + stringMaxLengths[result] = value.length result } @@ -310,6 +314,8 @@ class TsState( dfltObject = dfltObject, dfltObjectFieldSorts = dfltObjectFieldSorts, stringConstantAllocatedRefs = stringConstantAllocatedRefs, + stringMaxLengths = stringMaxLengths.toMutableMap(), + denseInputArrays = denseInputArrays.toMutableMap(), activeUnknownCallModels = activeUnknownCallModels.toMutableList(), ) } @@ -317,3 +323,9 @@ class TsState( override val isExceptional: Boolean get() = methodResult is TsMethodResult.TsException } + +data class TsDenseInputArray( + val type: EtsArrayType, + val lengthRegion: UReadOnlyMemoryRegion<*, *>, + val elementRegion: UReadOnlyMemoryRegion<*, *>, +) diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt index 3cd941ce4..b308634a5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/ArrayStorage.kt @@ -14,11 +14,14 @@ import org.usvm.USort import org.usvm.api.memcpy import org.usvm.collection.array.UArrayRegion import org.usvm.collection.array.UArrayRegionId +import org.usvm.collection.array.length.UArrayLengthsRegionId import org.usvm.machine.TsContext import org.usvm.machine.TsSizeSort import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsDenseInputArray import org.usvm.machine.state.TsState import org.usvm.machine.types.TsUnresolvedArrayKind +import org.usvm.sizeSort /** Enumerates payload regions independently of whether an array was allocated or came from the input. */ internal inline fun TsContext.forEachArrayPayloadRegion(arrayType: EtsArrayType, action: (EtsType, USort) -> Unit) { @@ -73,3 +76,40 @@ internal fun TsState.initializeArrayKind( ) memory.setRegion(regionId, initialized) } + +/** Records that every slot below the current input length has an initialized value. */ +fun TsState.markDenseInputArray( + array: UConcreteHeapRef, + type: EtsArrayType, +) = with(ctx) { + val descriptor = arrayDescriptorOf(type) + val elementSort = typeToSort(type.elementType) + require(elementSort !is TsUnresolvedSort) { "A dense input array needs a concrete element sort" } + + val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) + val elementRegionId = UArrayRegionId(descriptor, elementSort) + denseInputArrays[array] = TsDenseInputArray( + type = type, + lengthRegion = memory.getRegion(lengthRegionId), + elementRegion = memory.getRegion(elementRegionId), + ) +} + +/** Returns true only while neither the recorded length nor payload region has changed. */ +internal fun TsState.isUnmodifiedDenseInputArray( + array: UConcreteHeapRef, + type: EtsArrayType, +): Boolean = with(ctx) { + val snapshot = denseInputArrays[array] ?: return false + if (snapshot.type != type) return false + + val descriptor = arrayDescriptorOf(type) + val elementSort = typeToSort(type.elementType) + if (elementSort is TsUnresolvedSort) return false + + val lengthRegionId = UArrayLengthsRegionId(sizeSort, descriptor) + val elementRegionId = UArrayRegionId(descriptor, elementSort) + + memory.getRegion(lengthRegionId) === snapshot.lengthRegion && + memory.getRegion(elementRegionId) === snapshot.elementRegion +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt new file mode 100644 index 000000000..2d898ffc1 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -0,0 +1,251 @@ +package org.usvm.util + +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.api.evalTypeEquals +import org.usvm.api.initializeArrayLength +import org.usvm.api.memcpy +import org.usvm.machine.TsSizeSort +import org.usvm.machine.state.TsState +import org.usvm.sizeSort + +internal val STRING_CHARACTER_ARRAY_TYPE = EtsArrayType(EtsNumberType, dimensions = 1) + +internal fun TsState.stringCharacters(receiver: UHeapRef): UHeapRef = with(ctx) { + memory.read(mkFieldLValue(addressSort, receiver, "value")).asExpr(addressSort) +} + +internal fun TsState.stringLength(receiver: UHeapRef): UExpr { + val characters = stringCharacters(receiver) + return memory.read(mkArrayLengthLValue(characters, STRING_CHARACTER_ARRAY_TYPE)) +} + +fun TsState.markStringMaxLength( + string: UConcreteHeapRef, + maxLength: Int, +) { + require(maxLength >= 0) { "String maximum length must be non-negative" } + stringMaxLengths[string] = maxLength +} + +private fun TsState.concreteStringMaxLength(string: UHeapRef): Int? = + (string as? UConcreteHeapRef)?.let(stringMaxLengths::get) + +private data class StringLengthBound( + val representative: UHeapRef, + val maxLength: Int, + val isTrackedString: UBoolExpr, +) + +private fun TsState.stringLengthBound(string: UHeapRef): StringLengthBound? = with(ctx) { + concreteStringMaxLength(string)?.let { maxLength -> + return StringLengthBound( + representative = string, + maxLength = maxLength, + isTrackedString = trueExpr, + ) + } + if (string is UConcreteHeapRef || stringMaxLengths.isEmpty()) { + return null + } + + val trackedStrings = stringMaxLengths.entries.toList() + val representative = trackedStrings.drop(1).fold(trackedStrings.first().key as UHeapRef) { fallback, entry -> + mkIte( + condition = mkHeapRefEq(string, entry.key), + trueBranch = entry.key, + falseBranch = fallback, + ) + } + StringLengthBound( + representative = representative, + maxLength = trackedStrings.maxOf { it.value }, + isTrackedString = mkOr(trackedStrings.map { (tracked, _) -> mkHeapRefEq(string, tracked) }), + ) +} + +internal fun TsState.allocateString( + length: UExpr, + maxLength: Int? = null, +): Pair = with(ctx) { + val string = memory.allocConcrete(EtsStringType) + val characters = memory.allocConcrete(STRING_CHARACTER_ARRAY_TYPE.elementType) + memory.initializeArrayLength( + arrayHeapRef = characters, + type = arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + sizeSort = sizeSort, + count = length, + ) + memory.write( + mkFieldLValue(addressSort, string, "value"), + characters, + guard = trueExpr, + ) + if (maxLength != null) { + markStringMaxLength(string = string, maxLength = maxLength) + } + + string to characters +} + +internal fun TsState.stringValueEqualsOrNull( + left: UHeapRef, + right: UHeapRef, +): UBoolExpr? = with(ctx) { + val leftBound = stringLengthBound(left) ?: return null + val rightBound = stringLengthBound(right) ?: return null + val leftCharacters = stringCharacters(leftBound.representative) + val rightCharacters = stringCharacters(rightBound.representative) + val leftLength = memory.read(mkArrayLengthLValue(leftCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val rightLength = memory.read(mkArrayLengthLValue(rightCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val lengthIsValid = mkAnd( + leftBound.isTrackedString, + mkBvSignedGreaterOrEqualExpr(leftLength, mkBv(0)), + mkBvSignedLessOrEqualExpr(leftLength, mkBv(leftBound.maxLength)), + rightBound.isTrackedString, + mkBvSignedGreaterOrEqualExpr(rightLength, mkBv(0)), + mkBvSignedLessOrEqualExpr(rightLength, mkBv(rightBound.maxLength)), + ) + val lengthsAreEqual = mkEq(leftLength, rightLength) + val charactersAreEqual = (0 until maxOf(leftBound.maxLength, rightBound.maxLength)).map { index -> + val symbolicIndex = mkBv(index) + val indexIsLive = mkBvSignedLessExpr(symbolicIndex, leftLength) + val leftCharacter = memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = leftCharacters, + index = symbolicIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + val rightCharacter = memory.read( + mkArrayIndexLValue( + sort = bv16Sort, + ref = rightCharacters, + index = symbolicIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + + mkImplies(indexIsLive, mkEq(leftCharacter, rightCharacter)) + } + + mkAnd(lengthIsValid, lengthsAreEqual, mkAnd(charactersAreEqual)) +} + +internal fun TsState.refOrStringValueEquals( + left: UHeapRef, + right: UHeapRef, +): UBoolExpr = with(ctx) { + val referencesAreEqual = mkHeapRefEq(left, right) + val stringValuesAreEqual = stringValueEqualsOrNull(left, right) ?: return referencesAreEqual + val bothAreStrings = mkAnd( + memory.types.evalTypeEquals(left, EtsStringType), + memory.types.evalTypeEquals(right, EtsStringType), + ) + + mkOr(referencesAreEqual, mkAnd(bothAreStrings, stringValuesAreEqual)) +} + +internal fun TsState.refOrStringTruthy(ref: UHeapRef): UBoolExpr = with(ctx) { + val referenceIsTruthy = mkAnd( + mkHeapRefEq(ref, mkTsNullValue()).not(), + mkHeapRefEq(ref, mkUndefinedValue()).not(), + ) + + stringMaxLengths.keys.fold(referenceIsTruthy) { fallback, trackedString -> + val trackedStringIsNonEmpty = mkEq(stringLength(trackedString), mkBv(0)).not() + mkIte( + condition = mkHeapRefEq(ref, trackedString), + trueBranch = trackedStringIsNonEmpty, + falseBranch = fallback, + ) + } +} + +internal fun TsState.copyStringRange( + receiver: UHeapRef, + from: UExpr, + length: UExpr, +): UConcreteHeapRef = with(ctx) { + val source = stringCharacters(receiver) + val (result, destination) = allocateString(length, maxLength = concreteStringMaxLength(receiver)) + memory.memcpy( + source, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = from, + fromDst = mkBv(0), + length = length, + ) + result +} + +internal fun TsState.concatStrings( + left: UHeapRef, + right: UHeapRef, +): UConcreteHeapRef = with(ctx) { + val leftCharacters = stringCharacters(left) + val rightCharacters = stringCharacters(right) + val leftLength = memory.read(mkArrayLengthLValue(leftCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val rightLength = memory.read(mkArrayLengthLValue(rightCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val resultLength = mkBvAddExpr(leftLength, rightLength) + val resultMaxLength = concreteStringMaxLength(left)?.let { leftMaxLength -> + concreteStringMaxLength(right)?.let { rightMaxLength -> + (leftMaxLength.toLong() + rightMaxLength.toLong()) + .takeIf { it <= Int.MAX_VALUE } + ?.toInt() + } + } + val (result, destination) = allocateString(resultLength, maxLength = resultMaxLength) + + memory.memcpy( + leftCharacters, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = mkBv(0), + fromDst = mkBv(0), + length = leftLength, + ) + memory.memcpy( + rightCharacters, + destination, + arrayDescriptorOf(STRING_CHARACTER_ARRAY_TYPE), + bv16Sort, + fromSrc = mkBv(0), + fromDst = leftLength, + length = rightLength, + ) + result +} + +internal fun TsState.stringFromCodeUnit(codeUnit: UExpr): UConcreteHeapRef = with(ctx) { + val (result, characters) = allocateString(mkBv(1), maxLength = 1) + val value = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = codeUnit, + bvSize = bv16Sort.sizeBits.toInt(), + isSigned = false, + ).asExpr(bv16Sort) + val character = mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = mkBv(0), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + memory.write( + character, + value, + guard = trueExpr, + ) + result +} diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index b464a89de..fcc28f3c0 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -2,6 +2,7 @@ declare class StringModelPrimitives { static length(receiver: string): number; static codeUnitAt(receiver: string, index: number): number; static fromCodeUnit(codeUnit: number): string; + static copyRange(receiver: string, start: number, end: number): string; } export class StringModels { @@ -60,6 +61,43 @@ export class StringModels { return StringModelPrimitives.codeUnitAt(receiver, integerIndex); } + static slice(receiver: string, start: number, end: number): string { + const length = StringModelPrimitives.length(receiver); + const from = StringModels.normalizeSliceIndex(start, length); + const to = StringModels.normalizeSliceIndex(end, length); + return StringModelPrimitives.copyRange(receiver, from, to < from ? from : to); + } + + static toUpperCase(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let result = ""; + let index = 0; + while (index < length) { + let codeUnit = StringModelPrimitives.codeUnitAt(receiver, index); + if (codeUnit >= 0x61 && codeUnit <= 0x7a) { + codeUnit -= 0x20; + } + result += StringModelPrimitives.fromCodeUnit(codeUnit); + index++; + } + return result; + } + + static toLowerCase(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let result = ""; + let index = 0; + while (index < length) { + let codeUnit = StringModelPrimitives.codeUnitAt(receiver, index); + if (codeUnit >= 0x41 && codeUnit <= 0x5a) { + codeUnit += 0x20; + } + result += StringModelPrimitives.fromCodeUnit(codeUnit); + index++; + } + return result; + } + static startsWith(receiver: string, searchString: string, position: number): boolean { const length = StringModelPrimitives.length(receiver); const searchLength = StringModelPrimitives.length(searchString); @@ -178,4 +216,22 @@ export class StringModels { return Math.floor(value); } + + private static normalizeSliceIndex(value: number, length: number): number { + if (value !== value) { + return 0; + } + if (value === Infinity) { + return length; + } + if (value === -Infinity) { + return 0; + } + + const integer = value < 0 ? -Math.floor(-value) : Math.floor(value); + if (integer < 0) { + return integer + length < 0 ? 0 : integer + length; + } + return integer > length ? length : integer; + } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt new file mode 100644 index 000000000..5357ecd57 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayIsArrayIntrinsicModelTest.kt @@ -0,0 +1,147 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.intrinsic.TsArrayIsArrayIntrinsicModel +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayIsArrayIntrinsicModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayIsArrayIntrinsicModel.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(projectFiles = listOf(sourceFile)) + + @Test + fun `scalars are not arrays`() { + val result = analyze("scalarValuesAreNotArrays", "missingArgumentIsNotArray") + + assertTrue(result.values.values.all { values -> assertIs(values.single()).value }) + assertEquals( + List(size = 6) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `ordinary arrays are arrays`() { + val result = analyze("ordinaryArraysAreArrays") + + assertTrue(assertIs(result.values.getValue("ordinaryArraysAreArrays").single()).value) + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `null and undefined parameters are not arrays`() { + val result = analyze("nullParameterIsNotArray", "undefinedParameterIsNotArray") + + result.values.values.forEach { values -> + assertFalse(assertIs(values.single()).value) + } + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `string and nested array parameters are arrays`() { + val result = analyze("stringArrayIsArray", "nestedArrayIsArray") + + result.values.values.forEach { values -> + assertTrue(assertIs(values.single()).value) + } + assertEquals( + List(size = 2) { TsArrayIsArrayIntrinsicModel.MODEL_ID }, + result.modelIds, + ) + } + + @Test + fun `non builtin isArray call is not claimed by the model`() { + val result = analyze("nonBuiltinIsArrayFallsBack") + + assertTrue(result.modelIds.isEmpty()) + assertTrue(result.events.none { event -> event.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + + private fun analyze(vararg methodNames: String): AnalysisResult { + val methods = methodNames.associateWith(::method) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(unknownCallFallback = TsResidualCallPolicy.STOP_PATH), + observer = observer, + ).use { machine -> + val states = machine.analyze(methods.values.toList()) + val values = methods.mapValues { (_, method) -> + states.filter { state -> state.entrypoint === method } + .map { state -> TsTestResolver().resolve(method, state).returnValue } + } + + AnalysisResult( + values = values, + events = observer.events.toList(), + ) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == DEFAULT_ARK_CLASS_NAME && clazz.declaringFile === sourceFile } + .methods + .single { method -> method.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: Map>, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 1_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index e8d3ee296..a0152c12b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -242,6 +242,122 @@ class TsSequenceEtsIrModelTest { assertTrue("ts.string.lastIndexOf" in result.modelIds) } + @Test + fun `string slice copies exact UTF16 code units`() { + val result = analyze(methodName = "stringSlicePreservesUtf16") + + assertEquals("😀", assertIs(result.values.single()).value) + assertTrue("ts.string.slice" in result.modelIds) + } + + @Test + fun `modeled strings compare by UTF-16 value`() { + val result = analyze(methodName = "stringModelsUseValueEquality") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.slice" in result.modelIds) + } + + @Test + fun `non-string references retain identity equality`() { + val result = analyze(methodName = "stringEqualityPreservesReferenceIdentity") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `fake reference strings compare by UTF-16 value`() { + val result = analyze(methodName = "stringValueEqualityThroughAny") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `string truthiness follows UTF-16 length`() { + val result = analyze(methodName = "stringTruthinessUsesLength") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `non-string truthiness does not read string storage`() { + val result = analyze(methodName = "nonStringTruthinessDoesNotReadStringStorage") + + assertTrue(assertIs(result.values.single()).value) + } + + @Test + fun `mixed object equality does not read string storage`() { + val result = analyze(methodName = "mixedObjectEqualityDoesNotReadStringStorage") + + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { value -> assertIs(value).value }) + } + + @Test + fun `ASCII casing composes with charAt slice and concatenation`() { + val capitalized = analyze(methodName = "stringCapitalizeAscii") + val empty = analyze(methodName = "stringCapitalizeEmpty") + + assertEquals("Hello", assertIs(capitalized.values.single()).value) + assertEquals("", assertIs(empty.values.single()).value) + assertTrue(setOf("ts.string.toUpperCase", "ts.string.toLowerCase").all(capitalized.modelIds::contains)) + } + + @Test + fun `ASCII casing leaves non ASCII strings to residual fallback`() { + val result = analyze(methodName = "stringCapitalizeNonAscii") + + assertTrue(result.values.isEmpty()) + assertTrue(result.events.any { event -> event.outcome == TsUnknownCallOutcome.PATH_STOPPED }) + } + + @Test + fun `legacy approximations use observable residual fallback with empty stop catalog`() { + val methodNames = listOf( + "legacyArrayConcat", + "legacyArrayFill", + "legacyArrayJoin", + "legacyArrayPush", + "legacyArrayReverse", + "legacyArraySlice", + "legacyArrayToString", + "legacyArrayUnshift", + ) + val options = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.STOP_PATH, + ) + + methodNames.forEach { methodName -> + val result = analyze(methodName = methodName, tsOptions = options) + + assertTrue(result.values.isEmpty(), methodName) + assertEquals(listOf(TsUnknownCallOutcome.PATH_STOPPED), result.events.map(TsUnknownCallEvent::outcome)) + assertTrue(result.events.single().decision is TsUnknownCallDecision.ResidualFallback, methodName) + assertEquals(TsUnknownCallFailureReason.PARTIAL_APPROXIMATION, result.events.single().failureReason) + } + } + + @Test + fun `legacy join uses fresh residual result instead of sentinel with empty fresh catalog`() { + val result = analyze( + methodName = "legacyArrayJoin", + tsOptions = TsOptions( + unknownCallModelSelection = TsUnknownCallModelSelection.Only(emptySet()), + unknownCallFallback = TsResidualCallPolicy.FRESH_SYMBOLIC_RETURN, + ), + ) + + assertEquals(listOf(TsUnknownCallOutcome.FRESH_SYMBOLIC_RETURN), result.events.map(TsUnknownCallEvent::outcome)) + assertEquals(1, result.values.size) + assertTrue( + result.values.none { value -> + value is TsTestValue.TsString && value.value == "joined_array_result" + }, + ) + } + private fun analyze( methodName: String, tsOptions: TsOptions = TsOptions(), diff --git a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt index 2f950432f..09f91dc9d 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/util/TsTestResolver.kt @@ -1,5 +1,7 @@ package org.usvm.util +import io.ksmt.expr.KBitVec16Value +import io.ksmt.expr.KBitVec32Value import io.ksmt.expr.KFpValue import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType @@ -309,8 +311,27 @@ open class TsTestStateResolver( private fun resolveAllocatedString( ref: UConcreteHeapRef, ): TsTestValue.TsString { - val value = ctx.getStringConstantValue(ref) ?: run { - error("String constant not found for ref: $ref") + ctx.getStringConstantValue(ref)?.let { value -> + return TsTestValue.TsString(value) + } + + val charactersExpr = finalStateMemory.read(mkFieldLValue(ctx.addressSort, ref, "value")) + val characters = evaluateInModel(charactersExpr) as UConcreteHeapRef + val lengthExpr = finalStateMemory.read(mkArrayLengthLValue(characters, STRING_CHARACTER_ARRAY_TYPE)) + val length = (model.eval(lengthExpr) as KBitVec32Value).intValue + val value = buildString(length) { + repeat(length) { index -> + val codeUnitExpr = finalStateMemory.read( + mkArrayIndexLValue( + sort = ctx.bv16Sort, + ref = characters, + index = ctx.mkSizeExpr(index), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + ) + val codeUnit = (model.eval(codeUnitExpr) as KBitVec16Value).shortValue.toInt() and 0xffff + append(codeUnit.toChar()) + } } return TsTestValue.TsString(value) } diff --git a/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts b/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts new file mode 100644 index 000000000..22515dc2e --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayIsArrayIntrinsicModel.ts @@ -0,0 +1,38 @@ +export function scalarValuesAreNotArrays(): boolean { + return !Array.isArray(42) + && !Array.isArray(true) + && !Array.isArray('value') + && !Array.isArray(undefined) + && !Array.isArray(null); +} + +export function ordinaryArraysAreArrays(): boolean { + return Array.isArray([]) && Array.isArray([1, 2, 3]); +} + +export function stringArrayIsArray(value: string[]): boolean { + return Array.isArray(value); +} + +export function nestedArrayIsArray(value: number[][]): boolean { + return Array.isArray(value); +} + +export function missingArgumentIsNotArray(): boolean { + // @ts-expect-error Arity test deliberately omits the first argument. + return !Array.isArray(); +} + +export function nullParameterIsNotArray(value: null): boolean { + return Array.isArray(value); +} + +export function undefinedParameterIsNotArray(value: undefined): boolean { + return Array.isArray(value); +} + +declare const FakeArray: any; + +export function nonBuiltinIsArrayFallsBack(): boolean { + return FakeArray.isArray([]); +} diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts index 0a34f1034..9c6cbde6c 100644 --- a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -128,4 +128,99 @@ export class SequenceEtsIr { + "ababa".lastIndexOf("ba", 2) * 10 + "ababa".lastIndexOf("", Infinity); } + + stringSlicePreservesUtf16(): string { + return "A😀Z".slice(1, 3); + } + + stringModelsUseValueEquality(): boolean { + const whole = "abc".slice(0); + const independent = "abc".slice(0); + const empty = "abc".slice(0, 0); + + return whole === "abc" + && whole == "abc" + && whole === independent + && empty === "" + && empty === "x".slice(1) + && empty !== "a" + && whole + "d" === "abcd"; + } + + stringEqualityPreservesReferenceIdentity(): boolean { + const alias: number[] = [1]; + return alias === alias && alias !== [1]; + } + + stringValueEqualityThroughAny(): boolean { + const whole: any = "abc".slice(0); + const empty: any = "abc".slice(0, 0); + return whole === "abc" && whole == "abc" && empty === ""; + } + + stringTruthinessUsesLength(): boolean { + const empty = "abc".slice(0, 0); + const nonEmpty = "abc".slice(0, 1); + return !empty && !"" && !!nonEmpty; + } + + nonStringTruthinessDoesNotReadStringStorage(): boolean { + const object = { value: 1 }; + const array: number[] = []; + return !undefined && !null && !!object && !!array; + } + + mixedObjectEqualityDoesNotReadStringStorage(index: number): boolean { + if (index !== 0 && index !== 1) return true; + const dummy = "x"; + const values: any[] = [{}, {}]; + return values[index] !== ""; + } + + stringCapitalizeAscii(): string { + const value = "hELLO"; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + stringCapitalizeEmpty(): string { + const value = ""; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + stringCapitalizeNonAscii(): string { + const value = "éCOLE"; + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + } + + legacyArrayPush(): number { + return [1].push(2); + } + + legacyArrayFill(): number[] { + return [1].fill(2); + } + + legacyArrayUnshift(): number { + return [1].unshift(2); + } + + legacyArrayJoin(): string { + return [1, 2].join("-"); + } + + legacyArraySlice(): number[] { + return [1, 2].slice(1); + } + + legacyArrayConcat(): number[] { + return [1].concat([2]); + } + + legacyArrayReverse(): number[] { + return [1, 2].reverse(); + } + + legacyArrayToString(): string { + return [1, 2].toString(); + } } From d57f7d9f274811ecf6d1bfb63b06e2139fe9514d Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 22:06:21 +0300 Subject: [PATCH 30/33] Fix symbolic value semantics and add bounded array and string models --- .../org/usvm/ts/calls/CallsExperiment.kt | 9 +- .../usvm/ts/calls/CallsIrReadinessIssue.kt | 206 +++++++++ .../org/usvm/ts/calls/CallsSourceReplay.kt | 44 +- .../org/usvm/ts/calls/CallsSymbolicInputs.kt | 73 +++- .../ts/calls/CallsUnknownCallTelemetry.kt | 40 ++ .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 84 +++- .../usvm/ts/calls/CallsSourceReplayTest.kt | 106 ++++- .../calls/CurrentTsCallsSymbolicEngineTest.kt | 404 +++++++++++++++++- .../calls/SourceTargetReplayFixture.ts | 42 ++ .../src/source-inspector-cli.ts | 312 ++++++++++++-- .../fastcheck/TypeScriptSourceInspector.kt | 132 +++++- usvm-ts/UNKNOWN_CALL_MODELS.md | 25 +- .../org/usvm/machine/TsInterpreterObserver.kt | 22 + .../usvm/machine/call/TsBuiltinGlobalType.kt | 43 ++ .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 400 ++++++++++++++++- .../intrinsic/TsArrayIsArrayIntrinsicModel.kt | 17 +- .../TsNumericIntrinsicModelFamily.kt | 25 +- .../intrinsic/TsStringEtsIrModelFamily.kt | 36 +- .../usvm/machine/expr/CallApproximations.kt | 9 +- .../expr/EcmaScriptNumericConversions.kt | 164 +++++++ .../kotlin/org/usvm/machine/expr/ReadArray.kt | 77 +++- .../kotlin/org/usvm/machine/expr/ReadField.kt | 9 +- .../org/usvm/machine/expr/ReadStringIndex.kt | 50 +++ .../org/usvm/machine/expr/TsExprResolver.kt | 155 +++---- .../org/usvm/machine/expr/WriteArray.kt | 65 ++- .../org/usvm/machine/expr/WriteField.kt | 68 +-- .../usvm/machine/interpreter/TsInterpreter.kt | 32 +- .../usvm/machine/operator/TsBinaryOperator.kt | 121 ++---- .../kotlin/org/usvm/machine/state/TsState.kt | 3 +- .../kotlin/org/usvm/util/StringStorage.kt | 14 +- .../usvm/machine/call/models/ArrayModels.ts | 101 +++++ .../usvm/machine/call/models/StringModels.ts | 47 ++ .../call/TsArrayMutationEtsIrModelTest.kt | 203 +++++++++ .../machine/call/TsArrayShiftReplayTest.kt | 11 +- .../call/TsInstanceCallReceiverTest.kt | 36 +- .../machine/call/TsSequenceEtsIrModelTest.kt | 25 +- .../expr/GlobalArrayAssignmentBoundaryTest.kt | 72 ++++ .../expr/NumericBoundarySemanticsTest.kt | 216 ++++++++++ .../usvm/samples/arrays/AllocatedArrays.kt | 44 +- .../org/usvm/samples/arrays/InputArrays.kt | 51 ++- .../resources/models/ArrayMutationEtsIr.ts | 59 +++ .../models/GlobalArrayAssignmentBoundary.ts | 11 + .../models/NumericBoundarySemantics.ts | 187 ++++++++ .../test/resources/models/SequenceEtsIr.ts | 21 + 44 files changed, 3416 insertions(+), 455 deletions(-) create mode 100644 usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt create mode 100644 usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts create mode 100644 usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt index d9c823e53..9f0106541 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsExperiment.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.usvm.PathSelectionStrategy +import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.call.TsResidualCallPolicy import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.ts.pbt.model.JsConcreteValue @@ -116,6 +117,7 @@ internal enum class CallsSymbolicStatus { UNREACHED, UNREPRESENTABLE, UNSUPPORTED, + RUNTIME_LIMITATION, TIMEOUT, TOOL_ERROR, UNMAPPED, @@ -133,6 +135,7 @@ internal data class CallsSymbolicSearchRequest( val seed: Long, val budget: Duration, val unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + val runtimeLimitationEventSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)? = null, ) internal data class CallsSymbolicSearchResult( @@ -401,7 +404,7 @@ internal class CallsExperimentRunner( target: CallsSourceTarget, seed: Long, profile: CallsExperimentProfile, - appendUnknownCall: (CallsUnknownCallRecord) -> Unit, + appendUnknownCall: (CallsRawRecord) -> Unit, ): CallsTargetResult { val request = CallsSymbolicSearchRequest( sourceRoot = sourceRoot, @@ -420,6 +423,10 @@ internal class CallsExperimentRunner( cell = request.cellIdentity(experimentId = manifest.experimentId), appendAndFlush = appendUnknownCall, ), + runtimeLimitationEventSink = callsRuntimeLimitationEventSink( + cell = request.cellIdentity(experimentId = manifest.experimentId), + appendAndFlush = appendUnknownCall, + ), ), ) val replay = symbolic.inputs?.let { inputs -> diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt new file mode 100644 index 000000000..c8a4771c8 --- /dev/null +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt @@ -0,0 +1,206 @@ +package org.usvm.ts.calls + +import org.jacodb.ets.model.EtsAddExpr +import org.jacodb.ets.model.EtsEntity +import org.jacodb.ets.model.EtsFieldRef +import org.jacodb.ets.model.EtsFileSignature +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberConstant +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsRawEntity +import org.jacodb.ets.model.EtsRawStmt +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.utils.getDeclaredLocals +import org.jacodb.ets.utils.getOperands +import org.usvm.machine.TsGraph + +internal data class CallsIrReadinessIssue( + val reasonCode: CallsSymbolicPreflightReasonCode, + val diagnostic: String, +) + +internal fun callsIrReadinessIssue( + method: EtsMethod, + graph: TsGraph, + source: String, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): CallsIrReadinessIssue? { + val sourceFile = method.signature.enclosingClass.file + val pending = ArrayDeque().apply { addLast(method) } + val visited = hashSetOf() + + while (pending.isNotEmpty()) { + val currentMethod = pending.removeFirst() + if (!visited.add(currentMethod)) continue + + currentMethod.irReadinessIssue( + source = source, + admittedLexicalEnvironment = admittedLexicalEnvironment, + )?.let { issue -> return issue } + + currentMethod.sameFileCallees(graph = graph, sourceFile = sourceFile) + .forEach(pending::addLast) + } + + return null +} + +private fun EtsMethod.sameFileCallees( + graph: TsGraph, + sourceFile: EtsFileSignature, +): Sequence = cfg.stmts.asSequence() + .flatMap { statement -> + runCatching { graph.callees(statement).toList() } + .getOrDefault(emptyList()) + .asSequence() + } + .filter { callee -> callee.signature.enclosingClass.file == sourceFile } + +private fun EtsMethod.irReadinessIssue( + source: String, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): CallsIrReadinessIssue? { + val statements = cfg.stmts + val entitiesByStatement = statements.associateWith { statement -> statement.walkEntities() } + val entities = entitiesByStatement.values.flatten() + + if (hasUnsupportedLexicalEnvironment(entities, admittedLexicalEnvironment)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.LEXICAL_ENVIRONMENT_UNSUPPORTED, + diagnostic = "Reachable EtsIR contains a runtime lexical environment in $name", + ) + } + + val rawNodes = buildList { + entitiesByStatement.forEach { (statement, statementEntities) -> + val sourceSnippet = statement.sourceSnippet(source) + if (statement is EtsRawStmt) { + add(RawNode(kind = statement.kind, extra = statement.extra, sourceSnippet = sourceSnippet)) + } + statementEntities.filterIsInstance().forEach { entity -> + add(RawNode(kind = entity.kind, extra = entity.extra, sourceSnippet = sourceSnippet)) + } + } + } + rawNodes.firstOrNull(RawNode::isDestructuring)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.DESTRUCTURING_UNSUPPORTED, name) + } + rawNodes.firstOrNull(RawNode::isSpread)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.SPREAD_UNSUPPORTED, name) + } + rawNodes.firstOrNull(RawNode::isRegexLiteral)?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, name) + } + rawNodes.firstOrNull()?.let { raw -> + return raw.issue(CallsSymbolicPreflightReasonCode.RAW_ENTITY_UNSUPPORTED, name) + } + + if (entities.any(::isPrototypeAccess)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.PROTOTYPE_ACCESS_UNSUPPORTED, + diagnostic = "Reachable EtsIR accesses a prototype property in $name", + ) + } + + if (entities.any(::requiresSymbolicNumberToString)) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + diagnostic = "Reachable EtsIR concatenates a string with a symbolic number in $name", + ) + } + + return null +} + +private fun EtsMethod.hasUnsupportedLexicalEnvironment( + entities: List, + admittedLexicalEnvironment: EtsLexicalEnvType?, +): Boolean { + if (getDeclaredLocals().any { local -> + val type = local.type as? EtsLexicalEnvType + type != null && type != admittedLexicalEnvironment + } + ) { + return true + } + + return entities.any { entity -> + val functionType = entity.type as? EtsFunctionType ?: return@any false + val environment = functionType.signature.parameters.firstOrNull()?.type as? EtsLexicalEnvType + environment != null && environment != admittedLexicalEnvironment + } +} + +private fun org.jacodb.ets.model.EtsStmt.sourceSnippet(source: String): String { + val origin = location.origin ?: return "" + if (origin.startOffset !in 0..source.length || origin.endOffset !in origin.startOffset..source.length) return "" + + return source.substring(origin.startOffset, origin.endOffset) +} + +private fun org.jacodb.ets.model.EtsStmt.walkEntities(): List { + val result = mutableListOf() + val pending = ArrayDeque() + getOperands().forEach(pending::addLast) + while (pending.isNotEmpty()) { + val entity = pending.removeFirst() + result += entity + entity.getOperands().forEach(pending::addLast) + } + + return result +} + +private fun isPrototypeAccess(entity: EtsEntity): Boolean = + entity is EtsFieldRef && entity.field.name == "prototype" + +private fun requiresSymbolicNumberToString(entity: EtsEntity): Boolean { + val addition = entity as? EtsAddExpr ?: return false + if (addition.type != EtsStringType) return false + + val symbolicNumberOnLeft = addition.left.type == EtsNumberType && addition.left !is EtsNumberConstant + val symbolicNumberOnRight = addition.right.type == EtsNumberType && addition.right !is EtsNumberConstant + return symbolicNumberOnLeft || symbolicNumberOnRight +} + +private data class RawNode( + val kind: String, + val extra: Map, + val sourceSnippet: String, +) { + private val normalizedDescription: String = sequenceOf(kind) + .plus(extra.entries.sortedBy { (key, _) -> key }.flatMap { (key, value) -> sequenceOf(key, value.toString()) }) + .plus(sourceSnippet) + .joinToString(separator = " ") + .lowercase() + + fun matches(vararg markers: String): Boolean = markers.any(normalizedDescription::contains) + + fun isDestructuring(): Boolean = matches("destruct", "bindingpattern", "binding pattern") || + DESTRUCTURING_DECLARATION.containsMatchIn(sourceSnippet) || + DESTRUCTURING_ASSIGNMENT.containsMatchIn(sourceSnippet) + + fun isSpread(): Boolean = matches("spread") || sourceSnippet.contains("...") + + fun isRegexLiteral(): Boolean = matches("regex", "regexp", "regular expression") || + REGEX_LITERAL.containsMatchIn(sourceSnippet) + + fun issue(reasonCode: CallsSymbolicPreflightReasonCode, methodName: String): CallsIrReadinessIssue = issue( + reasonCode = reasonCode, + diagnostic = "Reachable EtsIR contains unsupported raw node kind $kind in $methodName", + ) +} + +private val DESTRUCTURING_DECLARATION = Regex("""\b(?:const|let|var)\s*[\[{]""") +private val DESTRUCTURING_ASSIGNMENT = Regex("""(?:^|[;{}])\s*[\[{][^=]*[\]}]\s*=""") +private val REGEX_LITERAL = Regex("""/(?:\\.|[^/\r\n])+/[a-z]*""") + +private fun issue( + reasonCode: CallsSymbolicPreflightReasonCode, + diagnostic: String, +): CallsIrReadinessIssue = CallsIrReadinessIssue( + reasonCode = reasonCode, + diagnostic = diagnostic, +) diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt index fefbdaac2..d249b7959 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceReplay.kt @@ -9,6 +9,7 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunStatus import org.usvm.ts.pbt.fastcheck.FastCheckBackend import org.usvm.ts.pbt.fastcheck.PbtBackendException +import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.ConstantDomain @@ -155,12 +156,21 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { val resolved = resolveTarget(sourceRoots = sourceRoots, sourcePath = target.sourcePath) val source = Files.readString(resolved.source) requireTargetCoordinates(source = source, target = target) + val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" + val instrumented = instrumentSource( + sourcePath = resolved.source, + source = source, + exportName = entryPoint.exportName, + target = target, + marker = marker, + ) ?: return CallsSourceReplayResult( + status = CallsReplayStatus.UNMAPPED, + reason = "TypeScript AST does not identify the requested completed return", + ) val workspace = Files.createTempDirectory("usvm-ts-calls-replay-") return try { val overlayRoot = workspace.resolve("source-overlay") - val marker = "__usvm_source_target_${UUID.randomUUID().toString().replace('-', '_')}" - val instrumented = instrumentSource(source = source, target = target, marker = marker) createOverlay( sourceRoot = resolved.sourceRoot, overlayRoot = overlayRoot, @@ -253,10 +263,12 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } private fun instrumentSource( + sourcePath: Path, source: String, + exportName: String, target: CallsSourceTarget, marker: String, - ): String = when (target.mode) { + ): String? = when (target.mode) { CallsSourceTargetMode.ENTRY -> { val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" @@ -264,23 +276,15 @@ internal class OriginalTypeScriptTargetReplayer : CallsTargetReplayer { } CallsSourceTargetMode.COMPLETED_RETURN -> { - val expressionStart = target.returnExpressionStartOffset - val expressionEnd = target.returnExpressionEndOffset - if (expressionStart == null || expressionEnd == null) { - val markerStatement = ";(globalThis as Record)[${jsString(marker)}] = true;\n" - - source.substring(0, target.startOffset) + markerStatement + source.substring(target.startOffset) - } else { - val expression = source.substring(expressionStart, expressionEnd) - val wrappedExpression = """ - ((__usvm_completed_value: any) => { - (globalThis as Record)[${jsString(marker)}] = true; - return __usvm_completed_value; - })($expression) - """.trimIndent() - - source.substring(0, expressionStart) + wrappedExpression + source.substring(expressionEnd) - } + TypeScriptSourceInspector.instrumentCompletedReturn( + source = sourcePath, + exportName = exportName, + startOffset = target.startOffset, + endOffset = target.endOffset, + expressionStartOffset = target.returnExpressionStartOffset, + expressionEndOffset = target.returnExpressionEndOffset, + marker = marker, + ) } } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt index 708719d12..56137b47c 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSymbolicInputs.kt @@ -6,12 +6,17 @@ import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsStringType import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType import org.usvm.UBoolExpr +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.USort +import org.usvm.api.allocateConcreteRef import org.usvm.api.initializeArrayLength import org.usvm.api.makeSymbolicPrimitive import org.usvm.machine.TsContext @@ -23,6 +28,7 @@ import org.usvm.machine.state.TsState import org.usvm.model.UModelBase import org.usvm.sizeSort import org.usvm.ts.pbt.mapping.EtsInputBinding +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.BooleanDomain import org.usvm.ts.pbt.model.JsConcreteValue @@ -52,6 +58,7 @@ internal fun callsSymbolicInputPreflight(inputs: List): String? { internal class CallsSymbolicInputs( inputs: List, bindings: List, + private val lexicalEnvironment: EtsLexicalEnvironmentBinding?, ) { private val boundInputs: List> private var snapshots: List? = null @@ -70,15 +77,22 @@ internal class CallsSymbolicInputs( fun initialize(state: TsState) { check(snapshots == null) { "Calls symbolic inputs were initialized more than once" } - snapshots = boundInputs.map { (input, binding) -> + lexicalEnvironment?.let(state::initializeLexicalEnvironment) + val initializedSnapshots = boundInputs.map { (input, binding) -> state.initializeInput( stackSlot = binding.stackSlot, domain = input.domain, ) } + initializedSnapshots.filterIsInstance().forEach { snapshot -> + snapshot.markDenseInput(state) + } + snapshots = initializedSnapshots } fun sortOverride(ctx: TsContext, stackSlot: Int): USort? { + if (lexicalEnvironment?.stackSlot == stackSlot) return ctx.addressSort + val domain = boundInputs.singleOrNull { (_, binding) -> binding.stackSlot == stackSlot }?.first?.domain ?: return null @@ -100,6 +114,48 @@ internal class CallsSymbolicInputs( } } +private fun TsState.initializeLexicalEnvironment(binding: EtsLexicalEnvironmentBinding): Unit = with(ctx) { + val environmentType = binding.parameter.type as? EtsLexicalEnvType + ?: error("Mapped lexical environment does not have a lexical-environment type") + val environmentRef = allocateConcreteRef() + for (captured in environmentType.closures) { + initializeBuiltinCapture(captured = captured, environmentRef = environmentRef) + } + memory.write( + mkRegisterStackLValue(addressSort, binding.stackSlot), + environmentRef.asExpr(addressSort), + guard = trueExpr, + ) + saveSortForLocal(binding.stackSlot, addressSort) +} + +private fun TsState.initializeBuiltinCapture( + captured: EtsLocal, + environmentRef: UConcreteHeapRef, +): Unit = with(ctx) { + val value = when (captured.name) { + "Infinity" -> mkFpInf(signBit = false, sort = fp64Sort) + "NaN" -> mkFp64NaN() + else -> memory.allocConcrete( + EtsUnclearRefType( + name = captured.name, + typeParameters = emptyList(), + ), + ).asExpr(addressSort) + } + val expectedSort = typeToSort(captured.type).let { sort -> + if (sort == unresolvedSort) addressSort else sort + } + require(value.sort == expectedSort) { + "Builtin capture ${captured.name} has sort ${value.sort}, expected $expectedSort" + } + memory.write( + mkFieldLValue(expectedSort, environmentRef, captured.name), + value.asExpr(expectedSort), + guard = trueExpr, + ) +} + private fun TsState.initializeInput( stackSlot: Int, domain: PropertyDomain, @@ -241,9 +297,12 @@ private fun TsState.initializeArrayInput( guard = trueExpr, ) saveSortForLocal(stackSlot, addressSort) - markDenseInputArray(array = arrayRef, type = runtimeType) - - ArrayInputSnapshot(length = length, elements = elements) + ArrayInputSnapshot( + length = length, + elements = elements, + arrayRef = arrayRef, + runtimeType = runtimeType, + ) } private fun TsState.constrainLength( @@ -308,7 +367,13 @@ private data class StringInputSnapshot( private data class ArrayInputSnapshot( val length: UExpr, val elements: List, + val arrayRef: UConcreteHeapRef, + val runtimeType: EtsArrayType, ) : CallsInputSnapshot { + fun markDenseInput(state: TsState) { + state.markDenseInputArray(array = arrayRef, type = runtimeType) + } + override fun resolve(model: UModelBase): JsConcreteValue { val concreteLength = model.eval(length).extractInt() require(concreteLength in 0..elements.size) { "Resolved array length is outside its symbolic domain" } diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt index e110d0a2b..046a265d6 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsUnknownCallTelemetry.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.jacodb.ets.model.EtsMethodSignature import org.jacodb.ets.model.EtsNamespaceSignature +import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.call.TsResidualCallPolicy import org.usvm.machine.call.TsUnknownCallDecision import org.usvm.machine.call.TsUnknownCallEvent @@ -72,6 +73,45 @@ internal data class CallsUnknownCallRecord( } } +@Serializable +@SerialName("runtime-limitation") +internal data class CallsRuntimeLimitationRecord( + val cell: CallsExperimentCellIdentity, + val eventIndex: Int, + val callSite: CallsUnknownCallSite, + val reason: String, + val detail: String, +) : CallsRawRecord + +internal fun callsRuntimeLimitationEventSink( + cell: CallsExperimentCellIdentity, + appendAndFlush: (CallsRuntimeLimitationRecord) -> Unit, +): (TsRuntimeFeatureLimitationEvent) -> Unit { + var eventIndex = 0 + return { event -> + eventIndex++ + val location = event.statement.location + val origin = location.origin + val containingMethod = location.method.signature + val callSite = CallsUnknownCallSite( + sourcePath = containingMethod.enclosingClass.file.fileName, + statementIndex = location.index, + startOffset = origin?.startOffset, + endOffset = origin?.endOffset, + start = origin?.let { CallsSourcePosition(line = it.startLine, column = it.startColumn) }, + end = origin?.let { CallsSourcePosition(line = it.endLine, column = it.endColumn) }, + ) + val record = CallsRuntimeLimitationRecord( + cell = cell, + eventIndex = eventIndex, + callSite = callSite, + reason = event.reason.name, + detail = event.detail, + ) + appendAndFlush(record) + } +} + internal fun CallsSymbolicSearchRequest.cellIdentity(experimentId: String): CallsExperimentCellIdentity = CallsExperimentCellIdentity( experimentId = experimentId, diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index 0bcc90bbb..cebb59fd0 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -11,17 +11,21 @@ import org.usvm.SolverType import org.usvm.StateCollectionStrategy import org.usvm.UMachineOptions import org.usvm.machine.TsAnalysisStopReason +import org.usvm.machine.TsGraph import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.machine.call.TsUnknownCallModelSelection import org.usvm.machine.state.TsMethodResult import org.usvm.machine.state.TsState import org.usvm.statistics.UMachineObserver +import org.usvm.ts.pbt.fastcheck.TypeScriptCompletedReturnTargetKind import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import org.usvm.ts.pbt.manifest.PropertyManifest import org.usvm.ts.pbt.mapping.EtsInputBinding +import org.usvm.ts.pbt.mapping.EtsLexicalEnvironmentBinding import org.usvm.ts.pbt.mapping.EtsMappingStatus import org.usvm.ts.pbt.mapping.PropertyEtsMapper import org.usvm.ts.pbt.model.JsConcreteValue @@ -52,6 +56,13 @@ internal enum class CallsSymbolicPreflightReasonCode { ENTRY_MAPPING_UNMAPPED, ENTRY_MAPPING_AMBIGUOUS, LEXICAL_CAPTURE_UNSUPPORTED, + LEXICAL_ENVIRONMENT_UNSUPPORTED, + DESTRUCTURING_UNSUPPORTED, + SPREAD_UNSUPPORTED, + REGEX_LITERAL_UNSUPPORTED, + RAW_ENTITY_UNSUPPORTED, + PROTOTYPE_ACCESS_UNSUPPORTED, + SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, TARGET_ORIGIN_UNMAPPED, TARGET_ORIGIN_UNSUPPORTED, FRONTEND_OR_PREFLIGHT_ERROR, @@ -149,8 +160,12 @@ internal class CurrentTsCallsSymbolicEngine( val symbolicInputs = CallsSymbolicInputs( inputs = request.function.inputs, bindings = prepared.inputBindings, + lexicalEnvironment = prepared.lexicalEnvironment, + ) + val interpreterObserver = UnknownCallEventSinkObserver( + sink = request.unknownCallEventSink, + runtimeLimitationSink = request.runtimeLimitationEventSink, ) - val interpreterObserver = request.unknownCallEventSink?.let(::UnknownCallEventSinkObserver) val analysis = TsMachine( scene = scene, options = machineOptions, @@ -169,14 +184,24 @@ internal class CurrentTsCallsSymbolicEngine( val states = analysis.states if (states.isEmpty()) { val status = when (analysis.stopReason) { - TsAnalysisStopReason.EXHAUSTED -> CallsSymbolicStatus.UNREACHED + TsAnalysisStopReason.EXHAUSTED -> { + if (interpreterObserver.runtimeLimitations.isEmpty()) { + CallsSymbolicStatus.UNREACHED + } else { + CallsSymbolicStatus.RUNTIME_LIMITATION + } + } // The machine options above disable every stop condition except the per-target timeout. - TsAnalysisStopReason.STOPPED -> CallsSymbolicStatus.TIMEOUT + TsAnalysisStopReason.STOPPED -> { + CallsSymbolicStatus.TIMEOUT + } } return result( status = status, startedAt = startedAt, + diagnostic = interpreterObserver.runtimeLimitations.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Runtime feature limitations: "), ) } @@ -299,6 +324,18 @@ internal class CurrentTsCallsSymbolicEngine( } val method = mappedTarget.method + callsIrReadinessIssue( + method = method, + graph = TsGraph(scene), + source = sourceText, + admittedLexicalEnvironment = lexicalEnvironment?.parameter?.type as? EtsLexicalEnvType, + )?.let { issue -> + return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = issue.reasonCode, + diagnostic = issue.diagnostic, + ) + } val exactTargetCandidates = exactTargetCandidates(method, request.target) val targetObservation = when (request.target.mode) { CallsSourceTargetMode.ENTRY -> { @@ -322,17 +359,24 @@ internal class CurrentTsCallsSymbolicEngine( } CallsSourceTargetMode.COMPLETED_RETURN -> { + val completedReturnTargetKind = TypeScriptSourceInspector.completedReturnTargetKind( + source = source, + exportName = request.function.entryPoint.exportName, + startOffset = request.target.startOffset, + endOffset = request.target.endOffset, + expressionStartOffset = request.target.returnExpressionStartOffset, + expressionEndOffset = request.target.returnExpressionEndOffset, + ) ?: return CallsTargetPreparation.Rejected( + status = CallsSymbolicStatus.UNSUPPORTED, + reasonCode = CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, + diagnostic = "TypeScript AST does not identify the requested completed return", + ) val returnStatement = completedReturnCandidate( method = method, target = request.target, exactTargetCandidates = exactTargetCandidates, allowsUniqueOriginlessReturn = { - TypeScriptSourceInspector.isExportedExpressionArrowBody( - source = source, - exportName = request.function.entryPoint.exportName, - startOffset = request.target.startOffset, - endOffset = request.target.endOffset, - ) + completedReturnTargetKind == TypeScriptCompletedReturnTargetKind.EXPRESSION_ARROW }, ) ?: return CallsTargetPreparation.Rejected( status = CallsSymbolicStatus.UNSUPPORTED, @@ -348,6 +392,7 @@ internal class CurrentTsCallsSymbolicEngine( scene = scene, method = method, inputBindings = mappedTarget.bindings.inputs, + lexicalEnvironment = mappedTarget.bindings.lexicalEnvironment, targetObservation = targetObservation, exactTargetCandidateCount = exactTargetCandidates.size, ) @@ -499,6 +544,7 @@ internal class CurrentTsCallsSymbolicEngine( val scene: EtsScene, val method: EtsMethod, val inputBindings: List, + val lexicalEnvironment: EtsLexicalEnvironmentBinding?, val targetObservation: SourceTargetObservation, val exactTargetCandidateCount: Int, ) : CallsTargetPreparation @@ -562,10 +608,18 @@ internal class CurrentTsCallsSymbolicEngine( } private class UnknownCallEventSinkObserver( - private val sink: (TsUnknownCallEvent) -> Unit, + private val sink: ((TsUnknownCallEvent) -> Unit)?, + private val runtimeLimitationSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)?, ) : TsInterpreterObserver { + val runtimeLimitations = linkedSetOf() + override fun onUnknownCall(event: TsUnknownCallEvent) { - sink(event) + sink?.invoke(event) + } + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + runtimeLimitations += event.reason.name + runtimeLimitationSink?.invoke(event) } } @@ -580,11 +634,19 @@ internal class CurrentTsCallsSymbolicEngine( val SUPPORTED_BUILTIN_CAPTURES: Set = setOf( "Array", "Boolean", + "Date", "Error", + "Infinity", + "Map", "Math", + "NaN", "Number", "Object", + "RangeError", + "Set", "String", + "TypeError", + "isNaN", ) } } diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt index 1e5a38645..5f485bd83 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceReplayTest.kt @@ -134,6 +134,104 @@ class CallsSourceReplayTest { assertEquals(true, replay.invocation?.targetHit) } + @Test + fun `completed bare return preserves an untaken unbraced branch`() { + val fixture = fixture() + val target = fixture.target( + functionName = "bareConditionalCompletedReturn", + statement = "return;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + ) + + val replay = fixture.replay( + exportName = "bareConditionalCompletedReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return ignores a recursive child hit`() { + val fixture = fixture() + val target = fixture.target( + functionName = "recursiveCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "recursiveCompletedReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects a partial expression target`() { + val fixture = fixture() + val target = fixture.target( + functionName = "partialCaughtReturn", + statement = "value", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "value", + ) + + val replay = fixture.replay( + exportName = "partialCaughtReturn", + inputs = listOf(number(-1.0)), + target = target, + ) + + assertEquals(CallsReplayStatus.UNMAPPED, replay.status, replay.toString()) + } + + @Test + fun `completed return rejects when a finally return overrides it`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyOverridesCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "finallyOverridesCompletedReturn", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + + @Test + fun `completed return rejects when finally breaks to implicit fallthrough`() { + val fixture = fixture() + val target = fixture.target( + functionName = "finallyBreaksCompletedReturn", + statement = "return 11;", + mode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "11", + ) + + val replay = fixture.replay( + exportName = "finallyBreaksCompletedReturn", + inputs = emptyList(), + target = target, + ) + + assertEquals(CallsReplayStatus.REJECTED, replay.status, replay.toString()) + assertEquals(false, replay.invocation?.targetHit) + } + private fun fixture(): Fixture { val sourcePath = resourcePath("/calls/SourceTargetReplayFixture.ts") @@ -164,8 +262,12 @@ class CallsSourceReplayTest { returnExpression: String? = null, ): CallsSourceTarget { val functionStart = source.indexOf("function $functionName") - val startOffset = source.indexOf(statement, startIndex = functionStart) - check(functionStart >= 0 && startOffset >= 0) { "Missing $statement in $functionName" } + check(functionStart >= 0) { "Missing function $functionName" } + val functionBodyStart = source.indexOf('{', startIndex = functionStart) + val startOffset = source.indexOf(statement, startIndex = functionBodyStart) + check(functionBodyStart >= 0 && startOffset >= 0) { + "Missing $statement in $functionName" + } val endOffset = startOffset + statement.length val returnExpressionStartOffset = returnExpression?.let { expression -> source.indexOf(expression, startIndex = startOffset).also { expressionStart -> diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt index 6bf1814d2..90edabbb6 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -1,6 +1,9 @@ package org.usvm.ts.calls import org.junit.jupiter.api.io.TempDir +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.machine.call.TsUnknownCallDecision import org.usvm.machine.call.TsUnknownCallEvent import org.usvm.ts.pbt.model.ArrayDomain import org.usvm.ts.pbt.model.JsConcreteValue @@ -18,10 +21,37 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds +@Suppress("LargeClass") class CurrentTsCallsSymbolicEngineTest { @TempDir lateinit var directory: Path + @Test + fun `runtime limitation is reported instead of an unreached target`() { + val fixture = fixture( + source = """ + export function writesNamedProperty(value: number): boolean { + const values = [1]; + values[0.5] = value; + return true; + } + """.trimIndent(), + exportName = "writesNamedProperty", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val limitations = mutableListOf() + + val result = fixture.search( + modelIds = emptySet(), + runtimeLimitationEventSink = limitations::add, + ) + + assertEquals(CallsSymbolicStatus.RUNTIME_LIMITATION, result.status, result.toString()) + assertEquals(TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, limitations.single().reason) + assertTrue(result.diagnostic.orEmpty().contains("ARRAY_NAMED_PROPERTY_WRITE")) + } + @Test fun `bundled frontend accepts only the revision baked into the running build`() { val engine = CurrentTsCallsSymbolicEngine( @@ -110,7 +140,7 @@ class CurrentTsCallsSymbolicEngineTest { unknownCallEventSink = unknownCalls::add, ) - val inputs = assertNotNull(result.inputs, result.toString()) + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") assertTrue(assertIs(inputs[0]).elements.isNotEmpty()) assertTrue(assertIs(inputs[1]).toDouble().isNaN()) assertTrue(unknownCalls.isNotEmpty()) @@ -232,6 +262,40 @@ class CurrentTsCallsSymbolicEngineTest { assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) } + @Test + fun `completed return rejects a partial ordinary return expression`() { + val fixture = fixture( + source = "export function increment(value: number): number { return value + 1; }", + exportName = "increment", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "1", + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = "1", + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, preflight.status) + assertEquals(CallsSymbolicPreflightReasonCode.TARGET_ORIGIN_UNSUPPORTED, preflight.reasonCode) + } + + @Test + fun `completed return accepts a module-local arrow exported by clause`() { + val expression = "value > 0" + val fixture = fixture( + source = "const aliasedArrow = (value: number): boolean => $expression; export { aliasedArrow };", + exportName = "aliasedArrow", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = expression, + targetMode = CallsSourceTargetMode.COMPLETED_RETURN, + returnExpression = expression, + ) + + val preflight = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, preflight.status, preflight.toString()) + } + @Test fun `preflight rejects source coordinates that do not match offsets`() { val fixture = fixture( @@ -293,6 +357,342 @@ class CurrentTsCallsSymbolicEngineTest { assertTrue(result.diagnostic.orEmpty().contains("threshold")) } + @Test + fun `accepts genuine builtin lexical captures before search`() { + val fixture = fixture( + source = """ + export const usesBuiltinGlobals = (value: number): boolean => { + const map = new Map>([[value, new Set([value])]]); + const errors = [new Error(), new RangeError(), new TypeError()]; + if (Date.now() >= 0 || value === Infinity || value === NaN || isNaN(value)) { + return true; + } + return map.size === errors.length; + }; + """.trimIndent(), + exportName = "usesBuiltinGlobals", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight rejects regex raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function containsA(value: string): boolean { + if (/a/.test(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "containsA", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain(maxLength = 3))), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects regex raw entities in a reachable same-file helper`() { + val fixture = fixture( + source = """ + function helper(): boolean { + return /x/.test("x"); + } + + export function callsHelper(value: number): boolean { + return helper() && value > 0; + } + """.trimIndent(), + exportName = "callsHelper", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return helper() && value > 0;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight ignores regex raw entities in an unreachable same-file helper`() { + val fixture = fixture( + source = """ + function unusedHelper(): boolean { + return /x/.test("x"); + } + + export function ignoresHelper(value: number): boolean { + if (value > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "ignoresHelper", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.ELIGIBLE, result.status, result.toString()) + } + + @Test + fun `preflight rejects spread raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function copies(values: number[]): boolean { + const copy = [...values]; + if (copy.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "copies", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.SPREAD_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects destructuring raw entities with a stable reason`() { + val fixture = fixture( + source = """ + export function readsHead(values: number[]): boolean { + const [head, ...tail] = values; + if (head === 1 && tail.length >= 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "readsHead", + inputs = listOf( + PropertyInput( + name = "values", + domain = ArrayDomain(element = NumberDomain(), maxLength = 3), + ), + ), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.DESTRUCTURING_UNSUPPORTED, result.reasonCode, result.toString()) + } + + @Test + fun `preflight rejects nested runtime lexical environments`() { + val fixture = fixture( + source = """ + export function invokesClosure(value: number): boolean { + const predicate = () => value > 0; + if (predicate()) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "invokesClosure", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.LEXICAL_ENVIRONMENT_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects prototype property access`() { + val fixture = fixture( + source = """ + export function readsPrototype(): boolean { + if (Object.prototype !== undefined) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "readsPrototype", + inputs = emptyList(), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.PROTOTYPE_ACCESS_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `preflight rejects symbolic number to string conversion`() { + val fixture = fixture( + source = """ + export function formatsValue(value: number): boolean { + const text = "value=" + value; + if (text.length > 0) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "formatsValue", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals( + CallsSymbolicPreflightReasonCode.SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + result.reasonCode, + result.toString(), + ) + } + + @Test + fun `initializes captured Math for modeled arrow search and replay`() { + val fixture = fixture( + source = """ + export const floorsToThree = (value: number): boolean => { + if (Math.floor(value) === 3) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "floorsToThree", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.math.floor"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + val value = assertIs(inputs.single()).toDouble() + assertEquals(3.0, kotlin.math.floor(value)) + assertTrue( + unknownCalls.any { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.math.floor" + }, + ) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `initializes captured Number for modeled arrow search and replay`() { + val fixture = fixture( + source = """ + export const isIntegerThree = (value: number): boolean => { + if (Number.isInteger(value) && value > 2 && value < 4) { + return true; + } + return false; + }; + """.trimIndent(), + exportName = "isIntegerThree", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.number.isInteger"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertEquals(3.0, assertIs(inputs.single()).toDouble()) + assertTrue( + unknownCalls.any { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == "ts.number.isInteger" + }, + ) + fixture.assertReplayConfirmed(inputs) + } + + @Test + fun `does not model user defined Math and Number receivers`() { + val fixture = fixture( + source = """ + class UserMath { + floor(value: number): number { + return value + 1; + } + } + + class UserNumber { + isInteger(value: number): boolean { + return value === 2; + } + } + + export function usesShadowedGlobals(value: number): boolean { + const Math = new UserMath(); + const Number = new UserNumber(); + if (Math.floor(value) === 3 && Number.isInteger(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "usesShadowedGlobals", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf("ts.math.floor", "ts.number.isInteger"), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertEquals(2.0, assertIs(inputs.single()).toDouble()) + assertTrue(unknownCalls.none { event -> event.decision is TsUnknownCallDecision.ModelApplied }) + fixture.assertReplayConfirmed(inputs) + } + @Test fun `extracts original array before mutating shift and replays source`() { val fixture = fixture( @@ -418,6 +818,7 @@ class CurrentTsCallsSymbolicEngineTest { fun search( modelIds: Set, unknownCallEventSink: ((TsUnknownCallEvent) -> Unit)? = null, + runtimeLimitationEventSink: ((TsRuntimeFeatureLimitationEvent) -> Unit)? = null, ): CallsSymbolicSearchResult = engine.search( CallsSymbolicSearchRequest( sourceRoot = sourceRoot, @@ -430,6 +831,7 @@ class CurrentTsCallsSymbolicEngineTest { seed = 0, budget = 10.seconds, unknownCallEventSink = unknownCallEventSink, + runtimeLimitationEventSink = runtimeLimitationEventSink, ) ) diff --git a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts index 3a6fd54e3..3cddbf802 100644 --- a/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts +++ b/usvm-ts-calls/src/test/resources/calls/SourceTargetReplayFixture.ts @@ -37,3 +37,45 @@ export function finallyThrowsAfterReturnExpression(): string { export function completesBareReturn(): void { return /* no expression */ } + +export function bareConditionalCompletedReturn(value: number): void { + if (value > 0) return; +} + +export function recursiveCompletedReturn(value: number): number { + if (value === 0) { + return 11; + } + recursiveCompletedReturn(0); + return 22; +} + +function partialReturnFailure(): never { + throw new Error('partial return failed'); +} + +export function partialCaughtReturn(value: number): number { + try { + return value + partialReturnFailure(); + } catch { + return 0; + } +} + +export function finallyOverridesCompletedReturn(): number { + try { + return 11; + } finally { + return 22; + } +} + +export function finallyBreaksCompletedReturn(): number | undefined { + while (true) { + try { + return 11; + } finally { + break; + } + } +} diff --git a/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts index 1f30f1d93..9d1fd354f 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/source-inspector-cli.ts @@ -1,50 +1,314 @@ import { readFileSync } from 'node:fs' import ts from 'typescript' +const INSPECT_COMPLETED_RETURN = 'inspect-completed-return' +const INSTRUMENT_COMPLETED_RETURN = 'instrument-completed-return' +const NONE = '-' + function fail(message: string): never { process.stderr.write(`${message}\n`) process.exit(2) } -const [sourcePath, exportName, startText, endText] = process.argv.slice(2) -if (sourcePath === undefined || exportName === undefined || startText === undefined || endText === undefined) { - fail('Expected source path, export name, start offset, and end offset') +function parseOffset(text: string | undefined, label: string): number { + const offset = Number(text) + if (!Number.isSafeInteger(offset)) fail(`${label} must be a safe integer`) + return offset +} + +function parseOptionalOffset(text: string | undefined, label: string): number | undefined { + if (text === NONE) return undefined + return parseOffset(text, label) } -const startOffset = Number(startText) -const endOffset = Number(endText) -if (!Number.isSafeInteger(startOffset) || !Number.isSafeInteger(endOffset)) { - fail('Source offsets must be safe integers') +const [operation, sourcePath, exportName, targetStartText, targetEndText, expressionStartText, expressionEndText, marker] = + process.argv.slice(2) +if ( + operation === undefined || sourcePath === undefined || exportName === undefined || targetStartText === undefined || + targetEndText === undefined || expressionStartText === undefined || expressionEndText === undefined +) { + fail('Expected operation, source path, export name, target offsets, and expression offsets') +} +if (operation !== INSPECT_COMPLETED_RETURN && operation !== INSTRUMENT_COMPLETED_RETURN) { + fail(`Unknown source-inspector operation: ${operation}`) +} +if (operation === INSTRUMENT_COMPLETED_RETURN && marker === undefined) { + fail('Completed-return instrumentation requires a marker') } +const requiredSourcePath = sourcePath ?? fail('Missing source path') +const requiredExportName = exportName ?? fail('Missing export name') -const source = readFileSync(sourcePath, 'utf8') +const targetStart = parseOffset(targetStartText, 'Target start offset') +const targetEnd = parseOffset(targetEndText, 'Target end offset') +const expressionStart = parseOptionalOffset(expressionStartText, 'Expression start offset') +const expressionEnd = parseOptionalOffset(expressionEndText, 'Expression end offset') +if ((expressionStart === undefined) !== (expressionEnd === undefined)) { + fail('Expression offsets must be both present or both absent') +} + +const source = readFileSync(requiredSourcePath, 'utf8') const diagnostics = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.Latest }, - fileName: sourcePath, + fileName: requiredSourcePath, reportDiagnostics: true, }).diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? [] if (diagnostics.length > 0) { fail(`Cannot parse TypeScript source: ${ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')}`) } -const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) +const sourceFile = ts.createSourceFile(requiredSourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) -const bodies: ts.ConciseBody[] = [] -for (const statement of sourceFile.statements) { - if (!ts.isVariableStatement(statement)) continue - const exported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true - if (!exported) continue +type SupportedCallable = ts.FunctionDeclaration | ts.ArrowFunction + +interface CallableOwner { + callable: SupportedCallable + body: ts.ConciseBody + declaration: ts.Statement +} - for (const declaration of statement.declarationList.declarations) { - if (!ts.isIdentifier(declaration.name) || declaration.name.text !== exportName) continue - if (!declaration.initializer || !ts.isArrowFunction(declaration.initializer)) continue - if (ts.isBlock(declaration.initializer.body)) continue +interface CompletedReturnTarget { + kind: 'expression-arrow' | 'return-statement' + owner: CallableOwner + returnStatement?: ts.ReturnStatement +} + +interface Edit { + start: number + end: number + replacement: string +} + +function hasExportModifier(node: ts.Node & { modifiers?: ts.NodeArray }): boolean { + return node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true +} + +function exportedLocalNames(name: string): Set { + const names = new Set() + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name?.text === name && hasExportModifier(statement)) { + names.add(name) + } + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + if (statement.declarationList.declarations.some((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === name + )) { + names.add(name) + } + } + if ( + ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause) + ) { + for (const specifier of statement.exportClause.elements) { + if (!specifier.isTypeOnly && specifier.name.text === name) { + names.add(specifier.propertyName?.text ?? specifier.name.text) + } + } + } + } + + return names +} + +function exportedCallableOwners(name: string): CallableOwner[] { + const localNames = exportedLocalNames(name) + const owners: CallableOwner[] = [] + + for (const statement of sourceFile.statements) { + if ( + ts.isFunctionDeclaration(statement) && statement.body !== undefined && statement.name !== undefined && + localNames.has(statement.name.text) + ) { + owners.push({ callable: statement, body: statement.body, declaration: statement }) + } + if (!ts.isVariableStatement(statement)) continue + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !localNames.has(declaration.name.text)) continue + if (declaration.initializer === undefined || !ts.isArrowFunction(declaration.initializer)) continue + + owners.push({ callable: declaration.initializer, body: declaration.initializer.body, declaration: statement }) + } + } + + return owners.filter((owner, index) => owners.findIndex((candidate) => candidate.callable === owner.callable) === index) +} + +function nestedFunction(node: ts.Node, owner: SupportedCallable): boolean { + return node !== owner && ts.isFunctionLike(node) +} + +function returnStatements(owner: CallableOwner): ts.ReturnStatement[] { + if (!ts.isBlock(owner.body)) return [] + + const returns: ts.ReturnStatement[] = [] + function visit(node: ts.Node): void { + if (nestedFunction(node, owner.callable)) return + if (ts.isReturnStatement(node)) { + returns.push(node) + return + } + ts.forEachChild(node, visit) + } + visit(owner.body) + + return returns +} - bodies.push(declaration.initializer.body) +function onlyTriviaAndOptionalSemicolon(start: number, end: number): boolean { + if (start > end) return false + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + true, + ts.LanguageVariant.Standard, + source.slice(start, end), + ) + let semicolons = 0 + while (true) { + const token = scanner.scan() + if (token === ts.SyntaxKind.EndOfFileToken) return true + if (token !== ts.SyntaxKind.SemicolonToken || ++semicolons > 1) return false } } -const matchingBodies = bodies.filter((body) => - body.getStart(sourceFile, false) === startOffset && body.getEnd() === endOffset, -) -process.stdout.write(matchingBodies.length === 1 ? 'true' : 'false') +function matchesReturnStatement(statement: ts.ReturnStatement): boolean { + if (statement.getStart(sourceFile, false) !== targetStart) return false + + const expression = statement.expression + if (expression === undefined) { + if (expressionStart !== undefined || expressionEnd !== undefined) return false + const returnKeywordEnd = targetStart + 'return'.length + return targetEnd >= returnKeywordEnd && onlyTriviaAndOptionalSemicolon(returnKeywordEnd, targetEnd) + } + if (expressionStart === undefined || expressionEnd === undefined) return false + if (expression.getStart(sourceFile, false) !== expressionStart || expression.getEnd() !== expressionEnd) return false + + return targetEnd >= expressionEnd && onlyTriviaAndOptionalSemicolon(expressionEnd, targetEnd) +} + +function findCompletedReturnTarget(): CompletedReturnTarget | undefined { + const owners = exportedCallableOwners(requiredExportName) + if (owners.length !== 1) return undefined + const owner = owners[0] + if (owner === undefined) return undefined + + if (!ts.isBlock(owner.body)) { + const body = owner.body + if ( + body.getStart(sourceFile, false) === targetStart && body.getEnd() === targetEnd && + expressionStart === targetStart && expressionEnd === targetEnd + ) { + return { kind: 'expression-arrow', owner } + } + return undefined + } + + const matches = returnStatements(owner).filter(matchesReturnStatement) + const matchingReturn = matches[0] + return matches.length === 1 && matchingReturn !== undefined + ? { kind: 'return-statement', owner, returnStatement: matchingReturn } + : undefined +} + +function completionExpression(expression: ts.Expression, depthName: string, markerName: string, hit: boolean): string { + const valueName = `${markerName}_value` + const expressionText = source.slice(expression.getStart(sourceFile, false), expression.getEnd()) + + return `(((${valueName}: any) => { if (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = ${hit}; ` + + `return ${valueName}; })((${expressionText})))` +} + +function instrumentReturn( + statement: ts.ReturnStatement, + target: ts.ReturnStatement, + depthName: string, + markerName: string, +): Edit { + const hit = statement === target + if (statement.expression !== undefined) { + return { + start: statement.expression.getStart(sourceFile, false), + end: statement.expression.getEnd(), + replacement: completionExpression(statement.expression, depthName, markerName, hit), + } + } + + return { + start: statement.getStart(sourceFile, false), + end: statement.getEnd(), + replacement: `{ if (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = ${hit}; return; }`, + } +} + +function firstNonDirectiveOffset(body: ts.Block): number { + const firstNonDirective = body.statements.find((statement) => + !ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression) + ) + return firstNonDirective?.getFullStart() ?? body.getEnd() - 1 +} + +function applyEdits(edits: Edit[]): string { + const ordered = [...edits].sort((left, right) => right.start - left.start || right.end - left.end) + let result = source + let previousStart = source.length + for (const edit of ordered) { + if (edit.start < 0 || edit.end < edit.start || edit.end > previousStart) { + fail('Source-inspector edits overlap or escape the source file') + } + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end) + previousStart = edit.start + } + return result +} + +function instrumentCompletedReturn(target: CompletedReturnTarget, markerName: string): string { + const depthName = `${markerName}_depth` + const declarationOffset = target.owner.declaration.getStart(sourceFile, false) + const edits: Edit[] = [{ start: declarationOffset, end: declarationOffset, replacement: `let ${depthName} = 0;\n` }] + const body = target.owner.body + + if (!ts.isBlock(body)) { + const expression = body + edits.push({ + start: expression.getStart(sourceFile, false), + end: expression.getEnd(), + replacement: `{ ${depthName}++; try { return ${completionExpression(expression, depthName, markerName, true)}; ` + + `} finally { ${depthName}--; } }`, + }) + return applyEdits(edits) + } + + const selectedReturn = target.returnStatement + if (selectedReturn === undefined) fail('Return-statement target is missing its return statement') + for (const statement of returnStatements(target.owner)) { + edits.push(instrumentReturn(statement, selectedReturn, depthName, markerName)) + } + + const guardedBodyStart = firstNonDirectiveOffset(body) + edits.push({ + start: guardedBodyStart, + end: guardedBodyStart, + replacement: `${depthName}++; try {\n`, + }) + edits.push({ + start: body.getEnd() - 1, + end: body.getEnd() - 1, + replacement: `\nif (${depthName} === 1) ` + + `(globalThis as Record)[${JSON.stringify(markerName)}] = false; ` + + `} finally { ${depthName}--; }\n`, + }) + + return applyEdits(edits) +} + +const target = findCompletedReturnTarget() +if (operation === INSPECT_COMPLETED_RETURN) { + process.stdout.write(target?.kind ?? 'unsupported') +} else if (target === undefined) { + process.stdout.write('unsupported\n') +} else { + process.stdout.write(`ok\n${instrumentCompletedReturn(target, marker ?? fail('Missing marker'))}`) +} diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt index d6dbd1696..78fae3df8 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/TypeScriptSourceInspector.kt @@ -1,40 +1,126 @@ package org.usvm.ts.pbt.fastcheck import java.nio.file.Path -import java.util.concurrent.TimeUnit -/** Exact TypeScript-AST validation used when EtsIR omits an expression-bodied arrow's source origin. */ +enum class TypeScriptCompletedReturnTargetKind { + EXPRESSION_ARROW, + RETURN_STATEMENT, +} + +/** Exact TypeScript-AST validation and instrumentation for completed-return source targets. */ object TypeScriptSourceInspector { - fun isExportedExpressionArrowBody( + fun completedReturnTargetKind( source: Path, exportName: String, startOffset: Int, endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, nodeExecutable: String = "node", - ): Boolean { - val process = ProcessBuilder( - nodeExecutable, - FastCheckRuntime.sourceInspectorEntryPoint().toString(), - source.toString(), - exportName, - startOffset.toString(), - endOffset.toString(), - ).redirectErrorStream(true).start() - if (!process.waitFor(SOURCE_INSPECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly() - error("TypeScript source inspection timed out") - } + ): TypeScriptCompletedReturnTargetKind? { + val output = invoke( + operation = INSPECT_COMPLETED_RETURN, + source = source, + exportName = exportName, + startOffset = startOffset, + endOffset = endOffset, + expressionStartOffset = expressionStartOffset, + expressionEndOffset = expressionEndOffset, + nodeExecutable = nodeExecutable, + ) - val output = process.inputStream.bufferedReader().use { reader -> reader.readText() }.trim() - require(process.exitValue() == 0) { - "TypeScript source inspection failed with exit ${process.exitValue()}: $output" - } return when (output) { - "true" -> true - "false" -> false + "expression-arrow" -> TypeScriptCompletedReturnTargetKind.EXPRESSION_ARROW + "return-statement" -> TypeScriptCompletedReturnTargetKind.RETURN_STATEMENT + "unsupported" -> null else -> error("TypeScript source inspection returned an invalid response: $output") } } - private const val SOURCE_INSPECTION_TIMEOUT_SECONDS: Long = 10 + fun instrumentCompletedReturn( + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + marker: String, + nodeExecutable: String = "node", + ): String? { + val output = invoke( + operation = INSTRUMENT_COMPLETED_RETURN, + source = source, + exportName = exportName, + startOffset = startOffset, + endOffset = endOffset, + expressionStartOffset = expressionStartOffset, + expressionEndOffset = expressionEndOffset, + marker = marker, + nodeExecutable = nodeExecutable, + ) + + if (output == UNSUPPORTED_RESPONSE) return null + require(output.startsWith(SUCCESS_RESPONSE)) { + "TypeScript source instrumentation returned an invalid response" + } + + return output.removePrefix(SUCCESS_RESPONSE) + } + + private fun invoke( + operation: String, + source: Path, + exportName: String, + startOffset: Int, + endOffset: Int, + expressionStartOffset: Int?, + expressionEndOffset: Int?, + nodeExecutable: String, + marker: String? = null, + ): String { + require((expressionStartOffset == null) == (expressionEndOffset == null)) { + "Return expression offsets must be both present or both absent" + } + val command = buildList { + add(nodeExecutable) + add(FastCheckRuntime.sourceInspectorEntryPoint().toString()) + add(operation) + add(source.toString()) + add(exportName) + add(startOffset.toString()) + add(endOffset.toString()) + add(expressionStartOffset?.toString() ?: MISSING_OFFSET) + add(expressionEndOffset?.toString() ?: MISSING_OFFSET) + if (marker != null) add(marker) + } + val output = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = MAX_REQUEST_BYTES, + maxStdoutBytes = MAX_STDOUT_BYTES, + maxStderrBytes = MAX_STDERR_BYTES, + shutdownGraceMillis = SHUTDOWN_GRACE_MILLIS, + ).invoke( + command = command, + request = "", + timeoutMillis = SOURCE_INSPECTION_TIMEOUT_MILLIS, + reportedTimeoutMillis = SOURCE_INSPECTION_TIMEOUT_MILLIS, + description = "TypeScript source inspection", + ) + require(output.exitCode == 0) { + "TypeScript source inspection failed with exit ${output.exitCode}: ${output.stderr.trim()}" + } + + return output.stdout + } + + private const val INSPECT_COMPLETED_RETURN = "inspect-completed-return" + private const val INSTRUMENT_COMPLETED_RETURN = "instrument-completed-return" + private const val MISSING_OFFSET = "-" + private const val UNSUPPORTED_RESPONSE = "unsupported\n" + private const val SUCCESS_RESPONSE = "ok\n" + private const val SOURCE_INSPECTION_TIMEOUT_MILLIS = 10_000L + private const val SHUTDOWN_GRACE_MILLIS = 250L + private const val MAX_REQUEST_BYTES = 1 + private const val MAX_STDOUT_BYTES = 16 * 1024 * 1024 + private const val MAX_STDERR_BYTES = 64 * 1024 } diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 22426ec63..5c88bc396 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -60,14 +60,19 @@ Built-ins are singleton models or families implementing the sealed `TsBuiltInUnk `org.usvm.machine.call.intrinsic` package. Kotlin's sealed-subclass metadata discovers them automatically; a family supplies its parameterized models without a separate registry. Discovery and the default catalog are computed once. -The built-in catalog contains 62 public API names, plus three internal String storage primitives: +The built-in catalog includes the following public APIs and their internal storage primitives. Enumerate +`TsBuiltInUnknownCallModels.catalog().modelIds` for the exact IDs in a build. | ID | Implementation | Accepted calls | | --- | --- | --- | | `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | +| `ts.array.isArray` | Kotlin runtime-type primitive | The genuine global Array predicate, including null, undefined and fake-value wrappers. | | `ts.array.pop` | TypeScript/EtsIR body | Zero-argument `pop` on a definitely one-dimensional array that also satisfies the symbolic runtime type guard. | | `ts.array.includes`, `ts.array.indexOf`, `ts.array.lastIndexOf` | TypeScript/EtsIR bodies | One-dimensional arrays and numeric positions, with the missing-slot exclusions below. | -| `ts.string.charAt`, `ts.string.charCodeAt`, `ts.string.includes`, `ts.string.indexOf`, `ts.string.lastIndexOf`, `ts.string.startsWith`, `ts.string.endsWith` | TypeScript/EtsIR bodies | Initialized concrete strings; numeric positions. `charAt` requires a concrete position. | +| `ts.array.push`, `ts.array.fill`, `ts.array.reverse`, `ts.array.unshift`, `ts.array.slice`, `ts.array.concat` | TypeScript/EtsIR bodies with storage growth/allocation primitives | One-dimensional arrays with length/result at most 16. Push/unshift accept up to three arguments; resolved-sort arrays require matching element sorts, while mixed arrays retain runtime value kinds. Concat accepts one same-type array. Except push, these operations require a current dense-array proof. | +| `ts.string.charAt`, `ts.string.charCodeAt`, `ts.string.includes`, `ts.string.indexOf`, `ts.string.lastIndexOf`, `ts.string.startsWith`, `ts.string.endsWith` | TypeScript/EtsIR bodies | Strings in initialized UTF-16 storage, including symbolic code units and numeric positions. | +| `ts.string.slice`, `ts.string.substring`, `ts.string.trim`, `ts.string.trimStart`, `ts.string.trimEnd` | TypeScript/EtsIR bodies | UTF-16 range copying; substring clamps/swaps bounds, and trim uses the ECMAScript whitespace set. | +| `ts.string.toLowerCase`, `ts.string.toUpperCase` | TypeScript/EtsIR bodies | ASCII strings of at most 16 code units; other strings use residual fallback. | | `ts.math.abs`, `ts.math.ceil`, `ts.math.floor`, `ts.math.max`, `ts.math.min`, `ts.math.round`, `ts.math.sqrt`, `ts.math.trunc` | Kotlin FP primitives | Numeric arguments; dynamic coercions use fallback. | | `ts.number.isFinite`, `ts.number.isInteger`, `ts.number.isNaN`, `ts.number.isSafeInteger` | Kotlin FP/type primitives | Non-coercing Number predicates, including runtime-kind guards. | | `ts.date.*` (38 IDs) | TypeScript/EtsIR bodies | Numeric Date construction, `UTC`, fixed-clock `now`, getters, setters, `valueOf`, and source `toISOString`; see the Date boundary below. | @@ -90,6 +95,22 @@ model, including through an `any` alias. An unknown or non-array receiver does n the method is named `shift`. A definitely-array receiver with an unresolved element sort remains applicable and uses the fake-value representation described below. +Dense input proofs retain snapshots of the array length and element storage regions. A later write invalidates the +proof conservatively, including writes to another array sharing a region. Consequently, a chain of individually +modeled array methods can still use fallback after its first mutation or copy. + +### Runtime limitations and experimental outcomes + +`TsInterpreterObserver.onRuntimeFeatureLimitation` records feasible paths stopped by bounded array storage, such as +named-property writes or unsupported length growth. These events are separate from unknown-call model decisions. +The Calls runner writes them synchronously and reports `RUNTIME_LIMITATION` when search exhausts after such a stop +without reaching the target. An actual timeout remains `TIMEOUT`; a reached target still requires original-source replay. + +Calls preflight checks input binding, source/IR target mapping and known unsupported IR features before choosing a +model/fallback profile. Excluded functions remain in the corpus support ledger. Comparative runs use a frozen common +target set; failures discovered after that freeze remain scheduled failures. The analysis budget is 30 seconds per +target and seed; JVM/frontend startup and original-source replay have separate recorded limits. + ### `unknownCallFallback` The fallback is applied when: diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 132c6413b..3045eb253 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -4,6 +4,7 @@ import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsCallExpr import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsReturnStmt +import org.jacodb.ets.model.EtsStmt import org.jacodb.ets.model.EtsThrowStmt import org.usvm.UBoolExpr import org.usvm.machine.call.TsUnknownCallEvent @@ -21,6 +22,11 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } + /** Called when a feasible path is intentionally stopped at a bounded runtime feature. */ + fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + // default empty implementation + } + fun onAssignStatement( simpleValueResolver: TsSimpleValueResolver, stmt: EtsAssignStmt, @@ -74,3 +80,19 @@ interface TsInterpreterObserver : UInterpreterObserver { // default empty implementation } } + +/** A feasible execution path stopped because the runtime model deliberately omits [reason]. */ +data class TsRuntimeFeatureLimitationEvent( + val statement: EtsStmt, + val reason: TsRuntimeFeatureLimitationReason, + val detail: String, +) + +/** Stable identifiers for bounded runtime features reported by [TsRuntimeFeatureLimitationEvent]. */ +enum class TsRuntimeFeatureLimitationReason { + ARRAY_NAMED_PROPERTY_READ, + ARRAY_NAMED_PROPERTY_WRITE, + ARRAY_INDEX_GROWTH, + ARRAY_LENGTH_CAPACITY, + ARRAY_LENGTH_GROWTH, +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt new file mode 100644 index 000000000..288fc23f1 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsBuiltinGlobalType.kt @@ -0,0 +1,43 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsAnyType +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsMethodSignature +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsType +import org.jacodb.ets.model.EtsUnclearRefType +import org.jacodb.ets.model.EtsUnknownType +import org.jacodb.ets.model.EtsValue + +internal fun hasBuiltinGlobalOwner( + owner: EtsValue, + callee: EtsMethodSignature, + expectedName: String, +): Boolean = owner.type.isBuiltinGlobalType(expectedName) || + owner.isLegacyBuiltinGlobal(expectedName = expectedName, callee = callee) + +internal fun EtsType.isBuiltinGlobalType(expectedName: String): Boolean = when (expectedName) { + "Math" -> this is EtsUnclearRefType && name == expectedName + "Number" -> this is EtsFunctionType && isBuiltinNumberType() + else -> false +} + +private fun EtsFunctionType.isBuiltinNumberType(): Boolean { + val parameter = signature.parameters.singleOrNull() ?: return false + return signature.enclosingClass == EtsClassSignature.UNKNOWN && + signature.name.isEmpty() && + signature.returnType == EtsNumberType && + parameter.type == EtsAnyType && + parameter.isOptional && + !parameter.isRest +} + +private fun EtsValue.isLegacyBuiltinGlobal( + expectedName: String, + callee: EtsMethodSignature, +): Boolean = this is EtsLocal && + name == expectedName && + type == EtsUnknownType && + callee.enclosingClass == EtsClassSignature.UNKNOWN.copy(name = expectedName) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index f896269a5..05eef09bd 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -1,9 +1,13 @@ package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr +import io.ksmt.utils.cast import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsUnknownType +import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UExpr +import org.usvm.api.initializeArrayLength import org.usvm.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard @@ -11,15 +15,26 @@ import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter import org.usvm.machine.call.TsUnknownCall import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallModelCompletion +import org.usvm.machine.call.TsUnknownCallModelExecution +import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.state.TsState +import org.usvm.sizeSort import org.usvm.util.arrayStorageType import org.usvm.util.isUnmodifiedDenseInputArray +import org.usvm.util.mkArrayLengthLValue /** Built-in Array algorithms implemented by ordinary TypeScript bodies. */ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private const val CLASS_NAME = "ArrayModels" + private const val PRIMITIVES_CLASS_NAME = "ArrayModelPrimitives" private const val RESOURCE_NAME = "/org/usvm/machine/call/models/ArrayModels.ts" + private const val MAX_SOURCE_ARRAY_LENGTH = 16 + private const val MAX_VARIADIC_ARGUMENTS = 3 + private const val MAX_FILL_ARGUMENTS = 3 private val baseArtifact by lazy { loadBundledEtsIrUnknownCallModelArtifact( @@ -82,6 +97,100 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val pushDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val arguments = call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + if (!state.argumentsMatchArrayType(arrayType, arguments)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH - arguments.size) + } + + private val fillDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val value = call.arguments.firstOrNull()?.resolved + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val valueMatchesArrayType = state.argumentsMatchArrayType(arrayType, listOf(value)) + val isDenseInput = state.isUnmodifiedDenseInputArray(array, arrayType) + if (!valueMatchesArrayType || !isDenseInput) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH) + } + + private val denseReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + if (!state.isUnmodifiedDenseInputArray(array, arrayType)) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH) + } + + private val unshiftDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val arguments = call.arguments.map { argument -> + argument.resolved ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + if ( + !state.argumentsMatchArrayType(arrayType, arguments) || + !state.isUnmodifiedDenseInputArray(array, arrayType) + ) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + state.boundedArrayGuard(array, arrayType, maximumLength = MAX_SOURCE_ARRAY_LENGTH - arguments.size) + } + + private val concatDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + val (array, arrayType) = state.concreteArray(call, inputs) + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val other = inputs.getOrNull(1) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val otherStaticType = call.arguments.singleOrNull()?.source?.type + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + val otherType = state.arrayStorageType(other, otherStaticType) as? EtsArrayType + ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + if ( + arrayType != otherType || + !state.isUnmodifiedDenseInputArray(array, arrayType) || + !state.isUnmodifiedDenseInputArray(other, otherType) + ) { + return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr + } + + with(state.ctx) { + val firstLength = state.memory.read(mkArrayLengthLValue(array, arrayType)) + val secondLength = state.memory.read(mkArrayLengthLValue(other, otherType)) + val firstIsBounded = state.boundedArrayGuard( + array, + arrayType, + maximumLength = MAX_SOURCE_ARRAY_LENGTH, + ) + val secondIsBounded = state.boundedArrayGuard( + other, + otherType, + maximumLength = MAX_SOURCE_ARRAY_LENGTH, + ) + val resultLength = mkBvAddExpr(firstLength, secondLength) + val maximumResultLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val resultFits = mkBvSignedLessOrEqualExpr(resultLength, maximumResultLength) + mkAnd( + firstIsBounded, + secondIsBounded, + resultFits, + ) + } + } + private val optionalFromIndexAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> call.resolvedInstanceInputs()?.let { inputs -> when { @@ -110,6 +219,69 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val variadicMutationAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > MAX_VARIADIC_ARGUMENTS) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + buildList { + add(inputs.first()) + addAll(arguments) + repeat(MAX_VARIADIC_ARGUMENTS - arguments.size) { + add(state.ctx.mkUndefinedValue()) + } + add(state.ctx.mkFp64(arguments.size.toDouble())) + } + } + + private val fillAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.isEmpty() || arguments.size > MAX_FILL_ARGUMENTS) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + if ( + arguments.drop(1).any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + ) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val start = arguments.getOrNull(1) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFp64(0.0) + val end = arguments.getOrNull(2) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + listOf(inputs.first(), arguments.first(), start, end) + } + + private val sliceAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + val inputs = call.resolvedInstanceInputs() ?: return@TsEtsIrUnknownCallModelInputAdapter null + val arguments = inputs.drop(1) + if (arguments.size > 2 || arguments.any { value -> + value != state.ctx.mkUndefinedValue() && value.sort != state.ctx.fp64Sort + } + ) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val start = arguments.getOrNull(0) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFp64(0.0) + val end = arguments.getOrNull(1) + ?.takeUnless { it == state.ctx.mkUndefinedValue() } + ?: state.ctx.mkFpInf(signBit = false, state.ctx.fp64Sort) + listOf(inputs.first(), start, end) + } + + private val noArgumentsAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.isEmpty()) call.resolvedInstanceInputs() else null + } + override val models: List by lazy { listOf( sourceModel( @@ -131,6 +303,51 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { methodName = "lastIndexOf", inputAdapter = optionalLastIndexAdapter, ), + sourceModel( + id = "ts.array.push", + methodName = "push", + inputAdapter = variadicMutationAdapter, + domainGuard = pushDomain, + ), + sourceModel( + id = "ts.array.fill", + methodName = "fill", + inputAdapter = fillAdapter, + domainGuard = fillDomain, + ), + sourceModel( + id = "ts.array.reverse", + methodName = "reverse", + inputAdapter = noArgumentsAdapter, + domainGuard = denseReceiverDomain, + ), + sourceModel( + id = "ts.array.unshift", + methodName = "unshift", + inputAdapter = variadicMutationAdapter, + domainGuard = unshiftDomain, + ), + sourceModel( + id = "ts.array.slice", + methodName = "slice", + inputAdapter = sliceAdapter, + domainGuard = denseReceiverDomain, + ), + sourceModel( + id = "ts.array.concat", + methodName = "concat", + domainGuard = concatDomain, + ), + primitiveModel( + methodName = "grow", + arity = 2, + implementation = ::growArray, + ), + primitiveModel( + methodName = "allocateLike", + arity = 2, + implementation = ::allocateArrayLike, + ), ) } @@ -138,6 +355,7 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { id: String, methodName: String, inputAdapter: TsEtsIrUnknownCallModelInputAdapter = TsEtsIrUnknownCallModelInputAdapter.IDENTITY, + domainGuard: TsEtsIrUnknownCallModelDomainGuard = arrayDomain, ): TsUnknownCallModel { val target = TsUnknownCallTarget( methodName = methodName, @@ -149,9 +367,19 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { id = id, target = target, artifact = artifact(methodName), - domainGuard = arrayDomain, + domainGuard = domainGuard, inputAdapter = inputAdapter, - requiredModelIds = setOf(MATH_FLOOR_MODEL_ID).takeUnless { methodName == "pop" }.orEmpty(), + requiredModelIds = buildSet { + if (methodName in setOf("indexOf", "includes", "lastIndexOf", "fill", "reverse", "slice")) { + add(MATH_FLOOR_MODEL_ID) + } + if (methodName in setOf("push", "unshift")) { + add(PRIMITIVE_GROW_ID) + } + if (methodName in setOf("slice", "concat")) { + add(PRIMITIVE_ALLOCATE_LIKE_ID) + } + }, ) } @@ -165,6 +393,150 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return artifact.copy(entryPoint = entryPoint) } + private fun TsState.concreteArray( + call: TsUnknownCall, + inputs: List>, + ): Pair? { + val receiver = inputs.firstOrNull() as? UConcreteHeapRef ?: return null + if (with(ctx) { receiver.hasFakeValueBranch() }) return null + + val staticType = call.receiver?.source?.type ?: return null + val arrayType = arrayStorageType(receiver, staticType) as? EtsArrayType ?: return null + if (arrayType.dimensions != 1) return null + + return receiver to arrayType + } + + private fun TsState.argumentsMatchArrayType( + arrayType: EtsArrayType, + arguments: List>, + ): Boolean = with(ctx) { + val elementSort = typeToSort(arrayType.elementType) + elementSort is TsUnresolvedSort || arguments.all { argument -> + argument.sort == elementSort && + (argument.sort != addressSort || !argument.asExpr(addressSort).hasFakeValueBranch()) + } + } + + private fun TsState.boundedArrayGuard( + array: UConcreteHeapRef, + arrayType: EtsArrayType, + maximumLength: Int, + ): UBoolExpr = with(ctx) { + val length = memory.read(mkArrayLengthLValue(array, arrayType)) + val minimumLength = mkBv(0) + val maximumLengthExpr = mkBv(maximumLength) + mkAnd( + memory.types.evalIsSubtype(array, arrayType), + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLengthExpr), + ) + } + + private fun primitiveModel( + methodName: String, + arity: Int, + implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ): TsUnknownCallModel = ArrayPrimitiveModel( + methodName = methodName, + arity = arity, + implementation = implementation, + ) + + private fun growArray( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef ?: return null + val fpLength = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val arrayType = state.arrayStorageType( + receiver, + EtsArrayType(EtsUnknownType, dimensions = 1), + ) as? EtsArrayType ?: return null + val length = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpLength, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val roundTrip = mkBvToFpExpr( + sort = fp64Sort, + roundingMode = fpRoundingModeSortDefaultValue(), + value = length.cast(), + signed = true, + ) + val receiverMatchesType = state.memory.types.evalIsSubtype(receiver, arrayType) + val minimumLength = mkBv(0) + val maximumLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val guard = mkAnd( + receiverMatchesType, + mkFpEqualExpr(roundTrip, fpLength), + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLength), + ) + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { + state.memory.write( + mkArrayLengthLValue(receiver, arrayType), + length, + guard = trueExpr, + ) + mkUndefinedValue() + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) + } + + private fun allocateArrayLike( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef ?: return null + val fpLength = inputs.getOrNull(1)?.takeIf { it.sort == fp64Sort }?.asExpr(fp64Sort) ?: return null + val arrayType = state.arrayStorageType( + receiver, + EtsArrayType(EtsUnknownType, dimensions = 1), + ) as? EtsArrayType ?: return null + val length = mkFpToBvExpr( + roundingMode = fpRoundingModeSortDefaultValue(), + value = fpLength, + bvSize = sizeSort.sizeBits.toInt(), + isSigned = true, + ).asExpr(sizeSort) + val receiverMatchesType = state.memory.types.evalIsSubtype(receiver, arrayType) + val minimumLength = mkBv(0) + val maximumLength = mkBv(MAX_SOURCE_ARRAY_LENGTH) + val guard = mkAnd( + receiverMatchesType, + mkBvSignedGreaterOrEqualExpr(length, minimumLength), + mkBvSignedLessOrEqualExpr(length, maximumLength), + ) + val successor = TsUnknownCallModelSuccessor( + guard = guard, + completion = TsUnknownCallModelCompletion.Normal { + val descriptor = arrayDescriptorOf(arrayType) + val result = state.memory.allocConcrete(descriptor) + state.memory.initializeArrayLength( + arrayHeapRef = result, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + result + }, + ) + + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) + } + private fun TsUnknownCall.resolvedInstanceInputs(): List>? { val resolvedReceiver = receiver?.resolved ?: return null val resolvedArguments = arguments.map { argument -> argument.resolved ?: return null } @@ -172,5 +544,29 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return listOf(resolvedReceiver) + resolvedArguments } + private class ArrayPrimitiveModel( + methodName: String, + private val arity: Int, + private val implementation: (TsState, List>) -> TsUnknownCallModelExecution?, + ) : TsUnknownCallModel { + override val id: String = "ts.array.primitive.$methodName" + override val target = TsUnknownCallTarget( + methodName = methodName, + enclosingClassName = PRIMITIVES_CLASS_NAME, + failureReason = TsUnknownCallFailureReason.METHOD_BODY_UNAVAILABLE, + ) + + override fun apply(state: TsState, call: TsUnknownCall): TsUnknownCallModelExecution? { + if (call.receiver != null || call.arguments.size != arity) { + return null + } + + val inputs = call.arguments.map { argument -> argument.resolved ?: return null } + return implementation(state, inputs) + } + } + private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" + private const val PRIMITIVE_GROW_ID = "ts.array.primitive.grow" + private const val PRIMITIVE_ALLOCATE_LIKE_ID = "ts.array.primitive.allocateLike" } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt index bfc81fb30..a238d155c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayIsArrayIntrinsicModel.kt @@ -69,22 +69,21 @@ internal object TsArrayIsArrayIntrinsicModel : TsBuiltInUnknownCallModel { } private fun TsState.isNonNullArrayReference(reference: UHeapRef): UBoolExpr = with(ctx) { + val isUndefined = mkEq(reference, mkUndefinedValue()) + val isNull = mkEq(reference, mkTsNullValue()) mkAnd( - mkNot(mkEq(reference, mkUndefinedValue())), - mkNot(mkEq(reference, mkTsNullValue())), + mkNot(isUndefined), + mkNot(isNull), memory.types.evalIsSubtype(reference, anyArrayType), ) } private fun TsState.normalExecution(result: UExpr<*>): TsUnknownCallModelExecution = with(ctx) { - TsUnknownCallModelExecution( - successors = listOf( - TsUnknownCallModelSuccessor( - guard = trueExpr, - completion = TsUnknownCallModelCompletion.Normal { result }, - ) - ) + val successor = TsUnknownCallModelSuccessor( + guard = trueExpr, + completion = TsUnknownCallModelCompletion.Normal { result }, ) + TsUnknownCallModelExecution(successors = listOf(successor)) } private const val UNKNOWN_SIGNATURE_COMPONENT: String = "%unk" diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt index 13b661479..af17e12e8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsNumericIntrinsicModelFamily.kt @@ -3,7 +3,6 @@ package org.usvm.machine.call.intrinsic import io.ksmt.expr.KFpRoundingMode import io.ksmt.sort.KFp64Sort import io.ksmt.utils.asExpr -import org.jacodb.ets.model.EtsLocal import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.machine.call.TsUnknownCall @@ -13,6 +12,7 @@ import org.usvm.machine.call.TsUnknownCallModelCompletion import org.usvm.machine.call.TsUnknownCallModelExecution import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.hasBuiltinGlobalOwner import org.usvm.machine.state.TsState internal object TsNumericIntrinsicModelFamily : TsBuiltInUnknownCallModelFamily { @@ -209,6 +209,13 @@ private fun unaryMathCall( } val argument = call.arguments.firstOrNull()?.resolved ?: return state.normalExecution(state.ctx.mkFp(Double.NaN, state.ctx.fp64Sort)) + with(state.ctx) { + if (argument.isFakeObject()) { + val numberGuard = argument.getFakeType(state.memory).fpTypeExpr + val result = operation(argument.extractFp(state.memory)) + return state.normalExecution(result, guard = numberGuard) + } + } if (argument.sort != state.ctx.fp64Sort) { return null } @@ -240,17 +247,23 @@ private fun variadicMathCall( } private fun TsUnknownCall.hasGlobalOwner(expectedName: String): Boolean { - val owner = receiver?.source as? EtsLocal ?: return false - return owner.name == expectedName + val owner = receiver?.source ?: return false + return hasBuiltinGlobalOwner(owner = owner, callee = callee, expectedName = expectedName) } -private fun TsState.normalExecution(result: UExpr<*>): TsUnknownCallModelExecution = with(ctx) { +private fun TsState.normalExecution( + result: UExpr<*>, + guard: UBoolExpr = ctx.trueExpr, +): TsUnknownCallModelExecution = with(ctx) { val successor = TsUnknownCallModelSuccessor( - guard = trueExpr, + guard = guard, completion = TsUnknownCallModelCompletion.Normal { result }, ) - TsUnknownCallModelExecution(successors = listOf(successor)) + TsUnknownCallModelExecution( + successors = listOf(successor), + residualGuard = guard.takeUnless { it == trueExpr }?.let(::mkNot), + ) } private fun TsState.isInteger(value: UExpr) = with(ctx) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 144f36268..4dbec10f5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -204,21 +204,13 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } - private val receiverAndConcreteIndexDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> - if (inputs.getOrNull(1) !is KFp64Value) { - state.ctx.falseExpr - } else { - receiverDomain.evaluate(state, call, inputs) - } - } - override val models: List by lazy { listOf( sourceModel( id = "ts.string.charAt", methodName = "charAt", inputAdapter = optionalIndexAdapter, - domainGuard = receiverAndConcreteIndexDomain, + domainGuard = receiverDomain, ), sourceModel( id = "ts.string.indexOf", @@ -262,6 +254,30 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { inputAdapter = sliceAdapter, domainGuard = receiverDomain, ), + sourceModel( + id = "ts.string.substring", + methodName = "substring", + inputAdapter = sliceAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.trim", + methodName = "trim", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.trimStart", + methodName = "trimStart", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), + sourceModel( + id = "ts.string.trimEnd", + methodName = "trimEnd", + inputAdapter = noArgumentsAdapter, + domainGuard = receiverDomain, + ), sourceModel( id = "ts.string.toUpperCase", methodName = "toUpperCase", @@ -319,7 +335,7 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { if (methodName in setOf("charAt", "toUpperCase", "toLowerCase")) { add(PRIMITIVE_FROM_CODE_UNIT_ID) } - if (methodName == "slice") { + if (methodName in setOf("slice", "substring", "trim", "trimStart", "trimEnd")) { add(PRIMITIVE_COPY_RANGE_ID) } }, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 6c93effe7..83e1f7d62 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -26,6 +26,7 @@ import org.usvm.machine.TsVirtualMethodCallStmt import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.TsUnknownCallModelDispatcher import org.usvm.machine.call.dispatch +import org.usvm.machine.call.hasBuiltinGlobalOwner import org.usvm.machine.call.isDateReceiver import org.usvm.machine.expr.TsExprApproximationResult.Companion.from import org.usvm.machine.interpreter.PromiseState @@ -58,7 +59,7 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( } // Handle `Number` calls. - if (expr.instance.name == "Number") { + if (hasBuiltinGlobalOwner(owner = expr.instance, callee = expr.callee, expectedName = "Number")) { when (expr.callee.name) { "isFinite", "isInteger", "isSafeInteger" -> { return tryDispatchNumericBuiltin(expr) @@ -88,7 +89,7 @@ internal fun TsExprResolver.tryApproximateGlobalInstanceCall( } // Handle `Math` method calls. - if (expr.instance.name == "Math") { + if (hasBuiltinGlobalOwner(owner = expr.instance, callee = expr.callee, expectedName = "Math")) { when (expr.callee.name) { "abs", "ceil", "max", "min", "round", "sqrt", "trunc" -> { return tryDispatchNumericBuiltin(expr) @@ -226,6 +227,10 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } val modeledStringMethods = setOf( + "substring", + "trim", + "trimStart", + "trimEnd", "charAt", "charCodeAt", "endsWith", diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt new file mode 100644 index 000000000..aa7a453b8 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/EcmaScriptNumericConversions.kt @@ -0,0 +1,164 @@ +package org.usvm.machine.expr + +import io.ksmt.expr.KFpRoundingMode +import io.ksmt.sort.KBvSort +import io.ksmt.sort.KFp64Sort +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.USort +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.types.extractValue + +private const val UINT32_MODULUS = 4_294_967_296.0 +private const val UINT32_MAX_VALUE = 4_294_967_295.0 + +internal data class TsNumericArrayIndex( + val value: UExpr, + val isNumeric: UBoolExpr, + val hasUnsupportedReadKey: UBoolExpr, +) + +/** Classifies an index that may be wrapped with another runtime type. */ +internal fun TsContext.extractNumericArrayIndex( + scope: TsStepScope, + index: UExpr, +): TsNumericArrayIndex { + val runtimeValues = scope.calcOnState { + with(ctx) { + val (_, boolGuard) = extractValue(index, boolSort, ::getIntermediateBoolLValue) + val (fpValue, fpGuard) = extractValue(index, fp64Sort, ::getIntermediateFpLValue) + val (reference, refGuard) = extractValue(index, addressSort, ::getIntermediateRefLValue) + + IndexRuntimeValues( + boolTypeGuard = boolGuard, + numericValue = fpValue, + numericTypeGuard = fpGuard, + refValue = reference, + refTypeGuard = refGuard, + ) + } + } + val refIsKnownMissingKey = if (runtimeValues.refValue == null) { + falseExpr + } else { + val refIsNull = mkHeapRefEq(runtimeValues.refValue, mkTsNullValue()) + val refIsUndefined = mkHeapRefEq(runtimeValues.refValue, mkUndefinedValue()) + val refIsNullish = mkOr(refIsNull, refIsUndefined) + + mkAnd( + runtimeValues.refTypeGuard, + refIsNullish, + ) + } + val hasSupportedRuntimeType = mkOr( + runtimeValues.boolTypeGuard, + runtimeValues.numericTypeGuard, + refIsKnownMissingKey, + ) + + return TsNumericArrayIndex( + value = runtimeValues.numericValue ?: mkFp64(0.0), + isNumeric = runtimeValues.numericTypeGuard, + hasUnsupportedReadKey = mkNot(hasSupportedRuntimeType), + ) +} + +private data class IndexRuntimeValues( + val boolTypeGuard: UBoolExpr, + val numericValue: UExpr?, + val numericTypeGuard: UBoolExpr, + val refValue: UHeapRef?, + val refTypeGuard: UBoolExpr, +) + +/** ECMAScript ToUint32, represented as the common 32-bit bit pattern used by ToInt32. */ +internal fun TsContext.mkEcmaScriptToUint32(value: UExpr): UExpr = with(this) { + val zero = mkFp64(0.0) + val isFinite = mkAnd( + mkFpIsNaNExpr(value).not(), + mkFpIsInfiniteExpr(value).not(), + ) + val finiteValue = mkIte(isFinite, value, zero) + val towardZero = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero) + val integer = mkFpRoundToIntegralExpr( + roundingMode = towardZero, + value = finiteValue, + ) + val modulus = mkFp64(UINT32_MODULUS) + val remainder = mkFpRemExpr(integer, modulus) + val remainderIsNegative = mkFpLessExpr(remainder, zero) + val positiveRemainder = mkFpAddExpr(fpRoundingModeSortDefaultValue(), remainder, modulus) + val normalizedRemainder = mkIte( + condition = remainderIsNegative, + trueBranch = positiveRemainder, + falseBranch = remainder, + ) + + mkFpToBvExpr( + roundingMode = towardZero, + value = normalizedRemainder, + bvSize = bv32Sort.sizeBits.toInt(), + isSigned = false, + ) +} + +internal fun TsContext.mkValidArrayIndexProperty( + value: UExpr, + maximumSupportedIndex: Int, +): UBoolExpr = mkFiniteIntegerInRange( + value = value, + minimum = 0.0, + maximum = maximumSupportedIndex.toDouble(), +) + +internal fun TsContext.mkValidArrayLength(value: UExpr): UBoolExpr = mkFiniteIntegerInRange( + value = value, + minimum = 0.0, + maximum = UINT32_MAX_VALUE, +) + +internal fun TsContext.mkFpToUint32AfterValidation( + value: UExpr, + validity: UBoolExpr, +): UExpr = with(this) { + val safeValue = mkIte( + condition = validity, + trueBranch = value, + falseBranch = mkFp64(0.0), + ) + mkFpToBvExpr( + roundingMode = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero), + value = safeValue, + bvSize = bv32Sort.sizeBits.toInt(), + isSigned = false, + ) +} + +private fun TsContext.mkFiniteIntegerInRange( + value: UExpr, + minimum: Double, + maximum: Double, +): UBoolExpr = with(this) { + val towardZero = mkFpRoundingModeExpr(KFpRoundingMode.RoundTowardZero) + val integer = mkFpRoundToIntegralExpr( + roundingMode = towardZero, + value = value, + ) + val isNotNaN = mkFpIsNaNExpr(value).not() + val isNotInfinite = mkFpIsInfiniteExpr(value).not() + val isInteger = mkFpEqualExpr(value, integer) + val minimumValue = mkFp64(minimum) + val maximumValue = mkFp64(maximum) + val isAtLeastMinimum = mkFpLessOrEqualExpr(minimumValue, value) + val isAtMostMaximum = mkFpLessOrEqualExpr(value, maximumValue) + + mkAnd( + isNotNaN, + isNotInfinite, + isInteger, + isAtLeastMinimum, + isAtMostMaximum, + ) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt index 4ffb6e227..13f2ddb0a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadArray.kt @@ -4,11 +4,17 @@ import io.ksmt.utils.asExpr import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsStringType +import org.usvm.UBoolExpr import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.isFalse +import org.usvm.isTrue import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope +import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.machine.types.mkFakeValue import org.usvm.machine.types.readUnresolvedArrayElement import org.usvm.sizeSort @@ -43,32 +49,40 @@ internal fun TsExprResolver.handleArrayAccess( // Resolve the index. val resolvedIndex = resolve(value.index) ?: return null - check(resolvedIndex.sort == fp64Sort) { - "Expected fp64 sort for index, got: ${resolvedIndex.sort}" + val index = extractNumericArrayIndex(scope, resolvedIndex) + if (scope.checkSat(index.hasUnsupportedReadKey) != null) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_READ, + detail = "property key requires unsupported ToPropertyKey conversion: $resolvedIndex", + ) + } + scope.assert(mkNot(index.hasUnsupportedReadKey)) ?: return null + + val storageType = scope.calcOnState { arrayStorageType(array, value.array.type) } + if (storageType is EtsStringType) { + return readStringIndex(scope, array, index.value, index.isNumeric) } - val index = resolvedIndex.asExpr(fp64Sort) - - // Convert the index to a bit-vector. - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = sizeSort.sizeBits.toInt(), - isSigned = true, - ).asExpr(sizeSort) - - val arrayType = scope.calcOnState { arrayStorageType(array, value.array.type) } - check(arrayType is EtsArrayType) { + check(storageType is EtsArrayType) { "Expected EtsArrayType, got: ${value.array.type}" } - // Read the array element. - readArray(scope, array, bvIndex, arrayType) + val indexIsSupported = mkAnd( + index.isNumeric, + mkValidArrayIndexProperty( + value = index.value, + maximumSupportedIndex = options.maxArraySize, + ), + ) + val bvIndex = mkFpToUint32AfterValidation(index.value, indexIsSupported).asExpr(sizeSort) + + readArrayProperty(scope, array, bvIndex, indexIsSupported, storageType) } -fun TsContext.readArray( +private fun TsContext.readArrayProperty( scope: TsStepScope, array: UHeapRef, index: UExpr, + indexIsSupported: UBoolExpr, arrayType: EtsArrayType, ): UExpr<*>? { checkNotFake(array) @@ -79,10 +93,33 @@ fun TsContext.readArray( memory.read(lengthLValue) } - // Check for out-of-bounds access. - checkNegativeIndexRead(scope, index) ?: return null - checkReadingInRange(scope, index, length) ?: return null + val elementExists = mkAnd( + indexIsSupported, + mkBvSignedLessExpr(index, length), + ) + if (elementExists.isFalse) { + return mkUndefinedValue() + } + + val element = readArrayElement(scope, array, index, arrayType) + if (elementExists.isTrue) { + return element + } + return iteWriteIntoFakeObject( + scope = scope, + condition = elementExists, + trueBranchValue = element, + falseBranchValue = mkUndefinedValue(), + ) +} + +private fun TsContext.readArrayElement( + scope: TsStepScope, + array: UHeapRef, + index: UExpr, + arrayType: EtsArrayType, +): UExpr<*> { // Determine the element sort. val sort = typeToSort(arrayType.elementType) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt index d063db251..8e6deace5 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt @@ -62,7 +62,7 @@ fun TsContext.readField( instance: UHeapRef, field: EtsFieldSignature, hierarchy: EtsHierarchy, -): UExpr<*> { +): UExpr<*>? { checkNotFake(instance) val sort = when (val etsField = resolveEtsField(instanceLocal, field, hierarchy)) { @@ -84,14 +84,15 @@ fun TsContext.readField( } if (!field.isDateModelTimestamp()) { - scope.doWithState { + val fieldExists = scope.calcOnState { // If we accessed some field, we make an assumption that // this field should present in the object. // That's not true in the common case for TS, but that's the decision we made. val auxiliaryType = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.assert(memory.types.evalIsSubtype(instance, auxiliaryType)) + memory.types.evalIsSubtype(instance, auxiliaryType) } + // A failed assertion stops this step; do not access its state afterward. + scope.assert(fieldExists) ?: return null } // If the field type is known, we can read it directly. diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt new file mode 100644 index 000000000..e3b837e67 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadStringIndex.kt @@ -0,0 +1,50 @@ +package org.usvm.machine.expr + +import io.ksmt.sort.KFp64Sort +import io.ksmt.utils.asExpr +import org.usvm.UBoolExpr +import org.usvm.UExpr +import org.usvm.UHeapRef +import org.usvm.machine.TsContext +import org.usvm.machine.interpreter.TsStepScope +import org.usvm.sizeSort +import org.usvm.util.STRING_CHARACTER_ARRAY_TYPE +import org.usvm.util.allocateString +import org.usvm.util.mkArrayIndexLValue +import org.usvm.util.stringCharacters +import org.usvm.util.stringLength + +/** Indexed string access returns one UTF-16 code unit, or undefined for a missing property. */ +internal fun TsContext.readStringIndex( + scope: TsStepScope, + string: UHeapRef, + index: UExpr, + indexIsNumeric: UBoolExpr, +): UExpr<*> = scope.calcOnState { + val indexIsValid = mkAnd( + indexIsNumeric, + mkValidArrayIndexProperty(index, maximumSupportedIndex = Int.MAX_VALUE), + ) + val storageIndex = mkFpToUint32AfterValidation(index, indexIsValid).asExpr(sizeSort) + val length = stringLength(string) + val indexIsInBounds = mkBvSignedLessExpr(storageIndex, length) + val propertyExists = mkAnd(indexIsValid, indexIsInBounds) + val characters = stringCharacters(string) + val sourceSlot = mkArrayIndexLValue( + sort = bv16Sort, + ref = characters, + index = storageIndex, + type = STRING_CHARACTER_ARRAY_TYPE, + ) + val codeUnit = memory.read(sourceSlot) + val (result, destination) = allocateString(length = mkBv(1), maxLength = 1) + val destinationSlot = mkArrayIndexLValue( + sort = bv16Sort, + ref = destination, + index = mkBv(0), + type = STRING_CHARACTER_ARRAY_TYPE, + ) + memory.write(destinationSlot, codeUnit, guard = trueExpr) + + mkIte(propertyExists, result, mkUndefinedValue()) +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index c33e8543a..c986e81e1 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -95,7 +95,10 @@ import org.usvm.dataflow.ts.util.type import org.usvm.isAllocatedConcreteHeapRef import org.usvm.machine.TsConcreteMethodCallStmt import org.usvm.machine.TsContext +import org.usvm.machine.TsInterpreterObserver import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.call.TsUnknownCallDispatcher import org.usvm.machine.call.TsUnknownCallFailureReason import org.usvm.machine.call.dispatch @@ -115,6 +118,7 @@ import org.usvm.machine.state.newStmt import org.usvm.machine.types.EtsNominalType import org.usvm.machine.types.iteWriteIntoFakeObject import org.usvm.sizeSort +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.SymbolResolutionResult import org.usvm.util.concatStrings @@ -123,7 +127,6 @@ import org.usvm.util.mkFieldLValue import org.usvm.util.mkRegisterStackLValue import org.usvm.util.resolveEtsMethods import org.usvm.util.resolveImportInfo -import org.usvm.types.singleOrNull private val logger = KotlinLogging.logger {} @@ -153,6 +156,7 @@ class TsExprResolver( internal val options: TsOptions, internal val hierarchy: EtsHierarchy, internal val unknownCallDispatcher: TsUnknownCallDispatcher, + internal val observer: TsInterpreterObserver?, ) : EtsEntity.Visitor?> { val simpleValueResolver: TsSimpleValueResolver = @@ -162,6 +166,20 @@ class TsExprResolver( return expr.accept(this) } + internal fun reportRuntimeFeatureLimitation( + reason: TsRuntimeFeatureLimitationReason, + detail: String, + ) { + val statement = scope.calcOnState { lastStmt } + observer?.onRuntimeFeatureLimitation( + TsRuntimeFeatureLimitationEvent( + statement = statement, + reason = reason, + detail = detail, + ) + ) + } + private fun resolveUnaryOperator( operator: TsUnaryOperator, expr: EtsUnaryExpr, @@ -310,13 +328,8 @@ class TsExprResolver( val arg = resolve(expr.arg) ?: return null val numericArg = mkNumericExpr(arg, scope) - // Convert to 32-bit integer, perform bitwise NOT, then convert back to number - val bvArg = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = numericArg.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true - ) + // Convert to the total ECMAScript 32-bit pattern, perform bitwise NOT, then convert back to number. + val bvArg = mkEcmaScriptToUint32(numericArg.asExpr(fp64Sort)) val notResult = mkBvNotExpr(bvArg) return mkBvToFpExpr( @@ -660,18 +673,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise AND, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvAndExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -685,18 +688,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise OR, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvOrExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -710,18 +703,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers, perform bitwise XOR, then convert back - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) val result = mkBvXorExpr(leftBv, rightBv) return mkBvToFpExpr(fp64Sort, fpRoundingModeSortDefaultValue(), result, signed = true) @@ -735,18 +718,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform left shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) as per JavaScript spec val shiftAmount = mkBvAndExpr( @@ -766,18 +739,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform signed right shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) val shiftAmount = mkBvAndExpr( @@ -802,18 +765,8 @@ class TsExprResolver( val rightNum = mkNumericExpr(right, scope) // Convert to 32-bit integers and perform unsigned right shift - val leftBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = leftNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) - val rightBv = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = rightNum.asExpr(fp64Sort), - bvSize = ECMASCRIPT_BITWISE_INTEGER_SIZE, - isSigned = true, - ) + val leftBv = mkEcmaScriptToUint32(leftNum.asExpr(fp64Sort)) + val rightBv = mkEcmaScriptToUint32(rightNum.asExpr(fp64Sort)) // Mask the shift amount to 5 bits (0-31) val shiftAmount = mkBvAndExpr( @@ -1114,33 +1067,25 @@ class TsExprResolver( TODO() } - val bvSize = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = size.asExpr(fp64Sort), - bvSize = 32, - isSigned = true, - ) + val fpSize = size.asExpr(fp64Sort) + val validLength = mkValidArrayLength(fpSize) - val condition = mkAnd( - mkEq( - mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = bvSize, - signed = true, - ), - size.asExpr(fp64Sort) - ), - mkAnd( - mkBvSignedLessOrEqualExpr(mkBv(0), bvSize.asExpr(bv32Sort)), - mkBvSignedLessOrEqualExpr(bvSize.asExpr(bv32Sort), mkBv(Int.MAX_VALUE)) + scope.fork( + validLength, + blockOnFalseState = { throwException("RangeError: Invalid array length: $fpSize") }, + ) ?: return@calcOnState null + + val maximumSupportedLength = mkFp64(options.maxArraySize.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr(fpSize, maximumSupportedLength) + if (scope.checkSat(mkNot(withinModelCapacity)) != null) { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + detail = "new Array length exceeds model capacity: $fpSize", ) - ) + } + scope.assert(withinModelCapacity) ?: return@calcOnState null - scope.fork( - condition, - blockOnFalseState = { throwException("Invalid array size: ${size.asExpr(fp64Sort)}") } - ) + val bvSize = mkFpToUint32AfterValidation(fpSize, validLength) if (arrayType.elementType is EtsArrayType) { TODO("Multidimensional arrays are not supported yet, https://github.com/UnitTestBot/usvm/issues/287") @@ -1148,7 +1093,7 @@ class TsExprResolver( val descriptor = arrayDescriptorOf(arrayType) val address = memory.allocConcrete(descriptor) - memory.initializeArrayLength(address, descriptor, sizeSort, bvSize) + memory.initializeArrayLength(address, descriptor, sizeSort, bvSize.asExpr(sizeSort)) address } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt index f24700c2f..4e8059379 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt @@ -1,11 +1,13 @@ package org.usvm.machine.expr import io.ksmt.utils.asExpr +import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayAccess import org.jacodb.ets.model.EtsArrayType import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.TsSizeSort import org.usvm.machine.interpreter.TsStepScope import org.usvm.sizeSort @@ -13,6 +15,8 @@ import org.usvm.util.arrayStorageType import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue +private val logger = KotlinLogging.logger {} + internal fun TsExprResolver.handleAssignToArrayIndex( lhv: EtsArrayAccess, expr: UExpr<*>, @@ -24,30 +28,57 @@ internal fun TsExprResolver.handleAssignToArrayIndex( } val array = resolvedArray.asExpr(addressSort) + handleAssignToArrayIndex(lhv, expr, array) +} + +internal fun TsExprResolver.handleAssignToArrayIndex( + lhv: EtsArrayAccess, + expr: UExpr<*>, + array: UHeapRef, +): Unit? = with(ctx) { // Check for undefined or null array access. checkUndefinedOrNullPropertyRead(scope, array, propertyName = "[]") ?: return null // Resolve the index. val resolvedIndex = resolve(lhv.index) ?: return null - check(resolvedIndex.sort == fp64Sort) { - "Expected fp64 sort for index, got: ${resolvedIndex.sort}" + val index = extractNumericArrayIndex(scope, resolvedIndex) + + val indexIsSupported = mkAnd( + index.isNumeric, + mkValidArrayIndexProperty( + value = index.value, + maximumSupportedIndex = options.maxArraySize, + ), + ) + if (scope.checkSat(mkNot(indexIsSupported)) != null) { + logger.warn { "Unsupported named array property write for key: $resolvedIndex" } + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + detail = "property key is not a supported numeric array index: $resolvedIndex", + ) } - val index = resolvedIndex.asExpr(fp64Sort) + scope.assert(indexIsSupported) ?: return null - // Convert the index to a bit-vector. - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = 32, - isSigned = true, - ).asExpr(sizeSort) + val bvIndex = mkFpToUint32AfterValidation(index.value, indexIsSupported).asExpr(sizeSort) val arrayType = scope.calcOnState { arrayStorageType(array, lhv.array.type) } check(arrayType is EtsArrayType) { "Expected EtsArrayType, got: ${lhv.array.type}" } - return assignToArrayIndex(scope, array, bvIndex, expr, arrayType) + return assignToArrayIndex( + scope = scope, + array = array, + index = bvIndex, + expr = expr, + arrayType = arrayType, + onUnsupportedGrowth = { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + detail = "array index write would grow beyond the current length", + ) + }, + ) } fun TsContext.assignToArrayIndex( @@ -56,6 +87,7 @@ fun TsContext.assignToArrayIndex( index: UExpr, expr: UExpr<*>, arrayType: EtsArrayType, + onUnsupportedGrowth: (() -> Unit)? = null, ): Unit? { checkNotFake(array) @@ -69,9 +101,14 @@ fun TsContext.assignToArrayIndex( // However, we decided to forbid this behavior in our model for simplicity. // Instead, we only allow writing to existing indices. - // Check for out-of-bounds access. - checkNegativeIndexRead(scope, index) ?: return null - checkReadingInRange(scope, index, length) ?: return null + val indexIsNonNegative = mkBvSignedGreaterOrEqualExpr(index, mkBv(0)) + val indexIsBelowLength = mkBvSignedLessExpr(index, length) + val indexIsInRange = mkAnd(indexIsNonNegative, indexIsBelowLength) + if (scope.checkSat(mkNot(indexIsInRange)) != null) { + logger.warn { "Unsupported array growth through index write: index=$index, length=$length" } + onUnsupportedGrowth?.invoke() + } + scope.assert(indexIsInRange) ?: return null val elementSort = typeToSort(arrayType.elementType) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 2273ad30f..2b1731d0e 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -12,6 +12,7 @@ import org.jacodb.ets.model.EtsStaticFieldRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.machine.TsContext +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.ensureStaticsInitialized import org.usvm.machine.types.EtsAuxiliaryType @@ -61,6 +62,7 @@ internal fun TsExprResolver.handleAssignToInstanceField( arrayType = arrayType, value = expr, maxArraySize = options.maxArraySize, + onFeatureLimitation = ::reportRuntimeFeatureLimitation, ) } @@ -74,6 +76,7 @@ private fun TsContext.assignToArrayLength( arrayType: EtsArrayType, value: UExpr<*>, maxArraySize: Int, + onFeatureLimitation: (TsRuntimeFeatureLimitationReason, String) -> Unit, ): Unit? = with(this) { val (fpLength, numericTypeGuard) = scope.calcOnState { with(ctx) { @@ -89,37 +92,41 @@ private fun TsContext.assignToArrayLength( return null } - val convertedLength = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = fpLength, - bvSize = 32, - isSigned = true, - ) - val roundTrip = mkBvToFpExpr( - sort = fp64Sort, - roundingMode = fpRoundingModeSortDefaultValue(), - value = convertedLength, - signed = true, - ) - val length = convertedLength.asExpr(sizeSort) + val validJsLength = mkValidArrayLength(fpLength) + scope.fork( + validJsLength, + blockOnFalseState = { throwException("RangeError: Invalid array length: $fpLength") }, + ) ?: return null + + val maximumSupportedLength = mkFp64(maxArraySize.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr(fpLength, maximumSupportedLength) + if (scope.checkSat(mkNot(withinModelCapacity)) != null) { + onFeatureLimitation( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + "assigned array length exceeds model capacity: $fpLength", + ) + } + scope.assert(withinModelCapacity) ?: run { + logger.warn { "Unsupported array length assignment beyond model capacity: $fpLength" } + return null + } + + val length = mkFpToUint32AfterValidation(fpLength, validJsLength).asExpr(sizeSort) val lengthLValue = mkArrayLengthLValue(array, arrayType) val currentLength = scope.calcOnState { memory.read(lengthLValue) } - val lengthIsIntegral = mkFpEqualExpr(roundTrip, fpLength) - val lengthIsNonNegative = mkBvSignedGreaterOrEqualExpr(length, mkBv(0)) - val lengthIsWithinLimit = mkBvSignedLessOrEqualExpr(length, mkBv(maxArraySize)) val lengthIsNotGrowing = mkBvSignedLessOrEqualExpr(length, currentLength) - val validLength = mkAnd( - lengthIsIntegral, - lengthIsNonNegative, - lengthIsWithinLimit, - lengthIsNotGrowing, - ) - scope.assert(validLength) ?: run { + if (scope.checkSat(mkNot(lengthIsNotGrowing)) != null) { + onFeatureLimitation( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_GROWTH, + "assigned array length would grow beyond the current length", + ) + } + scope.assert(lengthIsNotGrowing) ?: run { logger.warn { - "Unsupported array length assignment: expected an integral length in [0, current length], " + - "but the constraint is UNSAT: $validLength" + "Unsupported array length growth: expected length at most the current length, " + + "but the constraint is UNSAT: $lengthIsNotGrowing" } return null } @@ -136,7 +143,7 @@ fun TsContext.assignToInstanceField( field: EtsFieldSignature, expr: UExpr<*>, hierarchy: EtsHierarchy, -) { +): Unit? { // Unwrap to get non-fake reference. val unwrappedInstance = instance.unwrapRef(scope) @@ -145,10 +152,9 @@ fun TsContext.assignToInstanceField( // It is not always true for TS, but we decided to process it so. if (!field.isDateModelTimestamp()) { val supertype = EtsAuxiliaryType(properties = setOf(field.name)) - // assert is required to update models - scope.doWithState { - scope.assert(memory.types.evalIsSubtype(unwrappedInstance, supertype)) - } + val propertyExists = scope.calcOnState { memory.types.evalIsSubtype(unwrappedInstance, supertype) } + // The assertion is required to update models before the write. + scope.assert(propertyExists) ?: return null } // Determine the field sort. @@ -160,7 +166,7 @@ fun TsContext.assignToInstanceField( // If the field type is unknown, we create a fake object for the expr and assign it. // Otherwise, assign expr directly. - scope.doWithState { + return scope.doWithState { if (sort is TsUnresolvedSort) { val fakeObject = expr.toFakeObject(scope) val lValue = mkFieldLValue(addressSort, unwrappedInstance, field) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index 51546be72..41cab2635 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -34,8 +34,8 @@ import org.jacodb.ets.utils.callExpr import org.usvm.StepResult import org.usvm.StepScope import org.usvm.UExpr -import org.usvm.USort import org.usvm.UInterpreter +import org.usvm.USort import org.usvm.api.evalTypeEquals import org.usvm.api.initializeArray import org.usvm.api.targets.TsTarget @@ -79,7 +79,6 @@ import org.usvm.types.TypesResult import org.usvm.types.first import org.usvm.types.single import org.usvm.util.executableOverloadImplementation -import org.usvm.util.mkArrayIndexLValue import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkFieldLValue import org.usvm.util.mkRegisterStackLValue @@ -528,32 +527,8 @@ class TsInterpreter( "Expected address sort for the array, got: ${array.sort}" } val arrayRef = array.asExpr(addressSort) - val resolvedIndex = exprResolver.resolve(lhv.index) ?: return null - val index = resolvedIndex.asExpr(fp64Sort) - val bvIndex = mkFpToBvExpr( - roundingMode = fpRoundingModeSortDefaultValue(), - value = index, - bvSize = 32, - isSigned = true, - ).asExpr(sizeSort) - val arrayType = if (isAllocatedConcreteHeapRef(array)) { - scope.calcOnState { memory.typeStreamOf(array).first() } - } else { - lhv.array.type - } - check(arrayType is EtsArrayType) { - "Expected EtsArrayType, got: ${lhv.array.type}" - } - val elementSort = typeToSort(arrayType.elementType) - val elementLValue = mkArrayIndexLValue( - sort = elementSort, - ref = arrayRef, - index = bvIndex.asExpr(sizeSort), - type = arrayType, - ) - scope.doWithState { - memory.write(elementLValue, expr.cast(), guard = trueExpr) - } + + exprResolver.handleAssignToArrayIndex(lhv, expr, arrayRef) } } @@ -711,6 +686,7 @@ class TsInterpreter( options = options, hierarchy = graph.hierarchy, unknownCallDispatcher = unknownCallDispatcher, + observer = observer, ) fun getInitialState( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt index f3981e409..ab338365c 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/operator/TsBinaryOperator.kt @@ -7,6 +7,7 @@ import mu.KotlinLogging import org.usvm.UAddressSort import org.usvm.UBoolExpr import org.usvm.UBoolSort +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef import org.usvm.UIteExpr @@ -568,93 +569,55 @@ sealed interface TsBinaryOperator { ): UBoolExpr { check(lhs.isFakeObject() || rhs.isFakeObject()) - var lhsValue: UExpr<*> = lhs - var rhsValue: UExpr<*> = rhs - - val typeConstraint = when { - lhs.isFakeObject() && rhs.isFakeObject() -> { - val lhsType = lhs.getFakeType(scope) - val rhsType = rhs.getFakeType(scope) - mkAnd( - lhsType.boolTypeExpr eq rhsType.boolTypeExpr, - lhsType.fpTypeExpr eq rhsType.fpTypeExpr, - // TODO support type equality - lhsType.refTypeExpr eq rhsType.refTypeExpr, - ) - } + if (lhs.isFakeObject() && rhs.isFakeObject()) { + val lhsType = lhs.getFakeType(scope) + val rhsType = rhs.getFakeType(scope) + val boolValuesEqual = onBool(lhs.extractBool(scope), rhs.extractBool(scope), scope) + val fpValuesEqual = onFp(lhs.extractFp(scope), rhs.extractFp(scope), scope) + val refValuesEqual = onRef(lhs.extractRef(scope), rhs.extractRef(scope), scope) + val boolMatch = mkAnd(lhsType.boolTypeExpr, rhsType.boolTypeExpr, boolValuesEqual) + val fpMatch = mkAnd(lhsType.fpTypeExpr, rhsType.fpTypeExpr, fpValuesEqual) + val refMatch = mkAnd(lhsType.refTypeExpr, rhsType.refTypeExpr, refValuesEqual) + + return mkOr(boolMatch, fpMatch, refMatch) + } - lhs.isFakeObject() -> { - val lhsType = lhs.getFakeType(scope) - when (rhs.sort) { - boolSort -> { - lhsValue = lhs.extractBool(scope) - lhsType.boolTypeExpr - } - - fp64Sort -> { - lhsValue = lhs.extractFp(scope) - lhsType.fpTypeExpr - } - - // TODO support type equality - addressSort -> { - lhsValue = lhs.extractRef(scope) - lhsType.refTypeExpr - } - - else -> error("Unsupported sort ${rhs.sort}") - } - } + return if (lhs.isFakeObject()) { + compareFakeWithConcrete(lhs, rhs, scope) + } else { + check(rhs.isFakeObject()) + compareFakeWithConcrete(rhs, lhs, scope) + } + } - rhs.isFakeObject() -> { - val rhsType = rhs.getFakeType(scope) - when (lhs.sort) { - boolSort -> { - rhsValue = rhs.extractBool(scope) - rhsType.boolTypeExpr - } - - fp64Sort -> { - rhsValue = rhs.extractFp(scope) - rhsType.fpTypeExpr - } - - // TODO support type equality - addressSort -> { - rhsValue = rhs.extractRef(scope) - rhsType.refTypeExpr - } - - else -> error("Unsupported sort ${lhs.sort}") - } + private fun TsContext.compareFakeWithConcrete( + wrapped: UConcreteHeapRef, + concrete: UExpr<*>, + scope: TsStepScope, + ): UBoolExpr { + val type = wrapped.getFakeType(scope) + return when (concrete.sort) { + boolSort -> { + val payload = wrapped.extractBool(scope) + val equal = onBool(payload, concrete.asExpr(boolSort), scope) + mkAnd(type.boolTypeExpr, equal) } - else -> { - error("Should not be called") + fp64Sort -> { + val payload = wrapped.extractFp(scope) + val equal = onFp(payload, concrete.asExpr(fp64Sort), scope) + mkAnd(type.fpTypeExpr, equal) } - } - check(!lhsValue.isFakeObject()) { "Nested fake objects are not supported" } - check(!rhsValue.isFakeObject()) { "Nested fake objects are not supported" } - - // Note: this is the case 'ref === ref', - // which should be `true` only if both have the same reference. - // It is not correct to delegate to `Eq.resolve` in this case, - // since `==` treats `null == undefined`, while `null !== undefined`. - if (lhsValue.sort == addressSort && rhsValue.sort == addressSort) { - val left = lhsValue.asExpr(addressSort) - val right = rhsValue.asExpr(addressSort) - return mkAnd( - typeConstraint, - onRef(left, right, scope), - ) - } + addressSort -> { + val payload = wrapped.extractRef(scope) + check(!payload.isFakeObject()) { "Nested fake objects are not supported" } + val equal = onRef(payload, concrete.asExpr(addressSort), scope) + mkAnd(type.refTypeExpr, equal) + } - val looseEqualityConstraint = with(Eq) { - resolve(lhsValue, rhsValue, scope)?.asExpr(boolSort) ?: error("Should not be encountered") + else -> error("Unsupported sort ${concrete.sort}") } - - return mkAnd(typeConstraint, looseEqualityConstraint) } override fun TsContext.internalResolve( diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt index 1773cfcf3..93e31bfc8 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/state/TsState.kt @@ -29,8 +29,8 @@ import org.usvm.machine.TsContext import org.usvm.machine.interpreter.PromiseState import org.usvm.machine.interpreter.TsFunction import org.usvm.memory.ULValue -import org.usvm.memory.UReadOnlyMemoryRegion import org.usvm.memory.UMemory +import org.usvm.memory.UReadOnlyMemoryRegion import org.usvm.model.UModelBase import org.usvm.sizeSort import org.usvm.targets.UTargetsSet @@ -324,6 +324,7 @@ class TsState( get() = methodResult is TsMethodResult.TsException } +/** Storage snapshots used to invalidate the dense-input guarantee after writes. */ data class TsDenseInputArray( val type: EtsArrayType, val lengthRegion: UReadOnlyMemoryRegion<*, *>, diff --git a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt index 2d898ffc1..7492f999d 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/util/StringStorage.kt @@ -64,10 +64,11 @@ private fun TsState.stringLengthBound(string: UHeapRef): StringLengthBound? = wi falseBranch = fallback, ) } + val trackedStringGuards = trackedStrings.map { (tracked, _) -> mkHeapRefEq(string, tracked) } StringLengthBound( representative = representative, maxLength = trackedStrings.maxOf { it.value }, - isTrackedString = mkOr(trackedStrings.map { (tracked, _) -> mkHeapRefEq(string, tracked) }), + isTrackedString = mkOr(trackedStringGuards), ) } @@ -105,13 +106,16 @@ internal fun TsState.stringValueEqualsOrNull( val rightCharacters = stringCharacters(rightBound.representative) val leftLength = memory.read(mkArrayLengthLValue(leftCharacters, STRING_CHARACTER_ARRAY_TYPE)) val rightLength = memory.read(mkArrayLengthLValue(rightCharacters, STRING_CHARACTER_ARRAY_TYPE)) + val zeroLength = mkBv(0) + val leftMaximumLength = mkBv(leftBound.maxLength) + val rightMaximumLength = mkBv(rightBound.maxLength) val lengthIsValid = mkAnd( leftBound.isTrackedString, - mkBvSignedGreaterOrEqualExpr(leftLength, mkBv(0)), - mkBvSignedLessOrEqualExpr(leftLength, mkBv(leftBound.maxLength)), + mkBvSignedGreaterOrEqualExpr(leftLength, zeroLength), + mkBvSignedLessOrEqualExpr(leftLength, leftMaximumLength), rightBound.isTrackedString, - mkBvSignedGreaterOrEqualExpr(rightLength, mkBv(0)), - mkBvSignedLessOrEqualExpr(rightLength, mkBv(rightBound.maxLength)), + mkBvSignedGreaterOrEqualExpr(rightLength, zeroLength), + mkBvSignedLessOrEqualExpr(rightLength, rightMaximumLength), ) val lengthsAreEqual = mkEq(leftLength, rightLength) val charactersAreEqual = (0 until maxOf(leftBound.maxLength, rightBound.maxLength)).map { index -> diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 08daef0ba..14754943f 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -1,3 +1,8 @@ +declare class ArrayModelPrimitives { + static grow(receiver: any[], length: number): void; + static allocateLike(receiver: any[], length: number): any[]; +} + export class ArrayModels { static pop(receiver: any[]): any { const length = receiver.length; @@ -94,6 +99,86 @@ export class ArrayModels { return -1; } + static push(receiver: any[], first: any, second: any, third: any, count: number): number { + const length = receiver.length; + const resultLength = length + count; + ArrayModelPrimitives.grow(receiver, resultLength); + if (count >= 1) receiver[length] = first; + if (count >= 2) receiver[length + 1] = second; + if (count >= 3) receiver[length + 2] = third; + return resultLength; + } + + static fill(receiver: any[], value: any, start: number, end: number): any[] { + const length = receiver.length; + let index = ArrayModels.normalizeSliceIndex(start, length); + const final = ArrayModels.normalizeSliceIndex(end, length); + while (index < final) { + receiver[index] = value; + index++; + } + return receiver; + } + + static reverse(receiver: any[]): any[] { + const length = receiver.length; + let lower = 0; + while (lower < Math.floor(length / 2)) { + const upper = length - lower - 1; + const value = receiver[lower]; + receiver[lower] = receiver[upper]; + receiver[upper] = value; + lower++; + } + return receiver; + } + + static unshift(receiver: any[], first: any, second: any, third: any, count: number): number { + const length = receiver.length; + const resultLength = length + count; + ArrayModelPrimitives.grow(receiver, resultLength); + let index = length; + while (index > 0) { + index--; + receiver[index + count] = receiver[index]; + } + if (count >= 1) receiver[0] = first; + if (count >= 2) receiver[1] = second; + if (count >= 3) receiver[2] = third; + return resultLength; + } + + static slice(receiver: any[], start: number, end: number): any[] { + const length = receiver.length; + const from = ArrayModels.normalizeSliceIndex(start, length); + const to = ArrayModels.normalizeSliceIndex(end, length); + const count = to > from ? to - from : 0; + const result = ArrayModelPrimitives.allocateLike(receiver, count); + let index = 0; + while (index < count) { + result[index] = receiver[from + index]; + index++; + } + return result; + } + + static concat(receiver: any[], other: any[]): any[] { + const receiverLength = receiver.length; + const otherLength = other.length; + const result = ArrayModelPrimitives.allocateLike(receiver, receiverLength + otherLength); + let index = 0; + while (index < receiverLength) { + result[index] = receiver[index]; + index++; + } + let otherIndex = 0; + while (otherIndex < otherLength) { + result[receiverLength + otherIndex] = other[otherIndex]; + otherIndex++; + } + return result; + } + private static normalizeRelativeIndex(fromIndex: number, length: number): number { if (fromIndex !== fromIndex) { return 0; @@ -110,4 +195,20 @@ export class ArrayModels { const integer = fromIndex < 0 ? -Math.floor(-fromIndex) : Math.floor(fromIndex); return integer === 0 ? 0 : integer; } + + private static normalizeSliceIndex(index: number, length: number): number { + if (index !== index || index === -Infinity) { + return 0; + } + if (index === Infinity) { + return length; + } + + const integer = index < 0 ? -Math.floor(-index) : Math.floor(index); + if (integer < 0) { + const relative = length + integer; + return relative < 0 ? 0 : relative; + } + return integer > length ? length : integer; + } } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index fcc28f3c0..a591116ff 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -68,6 +68,53 @@ export class StringModels { return StringModelPrimitives.copyRange(receiver, from, to < from ? from : to); } + static substring(receiver: string, start: number, end: number): string { + const length = StringModelPrimitives.length(receiver); + const from = StringModels.normalizePosition(start, length); + const to = StringModels.normalizePosition(end, length); + return from <= to + ? StringModelPrimitives.copyRange(receiver, from, to) + : StringModelPrimitives.copyRange(receiver, to, from); + } + + static trim(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let start = 0; + let end = length; + while (start < end && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, start))) { + start++; + } + while (end > start && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, end - 1))) { + end--; + } + return StringModelPrimitives.copyRange(receiver, start, end); + } + + static trimStart(receiver: string): string { + const length = StringModelPrimitives.length(receiver); + let start = 0; + while (start < length && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, start))) { + start++; + } + return StringModelPrimitives.copyRange(receiver, start, length); + } + + static trimEnd(receiver: string): string { + let end = StringModelPrimitives.length(receiver); + while (end > 0 && StringModels.isWhitespace(StringModelPrimitives.codeUnitAt(receiver, end - 1))) { + end--; + } + return StringModelPrimitives.copyRange(receiver, 0, end); + } + + private static isWhitespace(codeUnit: number): boolean { + return (codeUnit >= 0x09 && codeUnit <= 0x0d) + || codeUnit === 0x20 || codeUnit === 0xa0 || codeUnit === 0x1680 + || (codeUnit >= 0x2000 && codeUnit <= 0x200a) + || codeUnit === 0x2028 || codeUnit === 0x2029 || codeUnit === 0x202f + || codeUnit === 0x205f || codeUnit === 0x3000 || codeUnit === 0xfeff; + } + static toUpperCase(receiver: string): string { const length = StringModelPrimitives.length(receiver); let result = ""; diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt new file mode 100644 index 000000000..a5bf28d74 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayMutationEtsIrModelTest.kt @@ -0,0 +1,203 @@ +package org.usvm.machine.call + +import io.ksmt.utils.asExpr +import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsNumberType +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.api.initializeArray +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.state.TsState +import org.usvm.sizeSort +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import org.usvm.util.markDenseInputArray +import org.usvm.util.mkRegisterStackLValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayMutationEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayMutationEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `push supports zero through three arguments in source`() { + val result = analyze(methodName = "pushSupportedArities") + + assertEquals(1447.0, result.singleNumber()) + assertEquals(setOf("ts.array.push", "ts.array.primitive.grow"), result.modelIds.toSet()) + assertEquals(3, result.modelIds.count { it == "ts.array.push" }) + } + + @Test + fun `push outside bounded arity uses residual fallback`() { + val result = analyze(methodName = "pushTooManyArguments") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + assertTrue(result.modelIds.isEmpty()) + } + + @Test + fun `fill normalizes negative fractions infinities and NaN`() { + val negative = analyze( + methodName = "fillNegativeFractionAndInfinity", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val nan = analyze( + methodName = "fillNaNAndFraction", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + + assertEquals(1299.0, negative.singleNumber()) + assertEquals(7234.0, nan.singleNumber()) + assertTrue(listOf(negative, nan).all { "ts.array.fill" in it.modelIds }) + } + + @Test + fun `reverse and unshift mutate bounded dense arrays`() { + val reversed = analyze( + methodName = "reverseDense", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val unshifted = analyze( + methodName = "unshiftDense", + denseInputs = listOf(listOf(1.0, 2.0)), + ) + + assertEquals(4321.0, reversed.singleNumber()) + assertEquals(47_812.0, unshifted.singleNumber()) + assertTrue("ts.array.reverse" in reversed.modelIds) + assertTrue("ts.array.unshift" in unshifted.modelIds) + } + + @Test + fun `slice and concat copy bounded dense arrays`() { + val sliced = analyze( + methodName = "sliceDense", + denseInputs = listOf(listOf(1.0, 2.0, 3.0, 4.0)), + ) + val concatenated = analyze( + methodName = "concatDense", + denseInputs = listOf(listOf(1.0, 2.0), listOf(3.0, 4.0)), + ) + + assertEquals(32_344.0, sliced.singleNumber()) + assertEquals(41_234.0, concatenated.singleNumber()) + assertTrue("ts.array.slice" in sliced.modelIds) + assertTrue("ts.array.concat" in concatenated.modelIds) + } + + @Test + fun `sparse reverse remains residual`() { + val result = analyze(methodName = "sparseReverseUsesResidual") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + assertTrue(result.modelIds.isEmpty()) + } + + private fun analyze( + methodName: String, + denseInputs: List> = emptyList(), + ): AnalysisResult { + val method = method(methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + initialStateConfigurator = { state -> initializeDenseInputs(state, denseInputs) }, + ).use { machine -> + val states = machine.analyze(listOf(method)) + AnalysisResult( + values = states.map { state -> TsTestResolver().resolve(method, state).returnValue }, + events = observer.events.toList(), + ) + } + } + + private fun initializeDenseInputs( + state: TsState, + inputs: List>, + ) = with(state.ctx) { + val arrayType = EtsArrayType(EtsNumberType, dimensions = 1) + val descriptor = arrayDescriptorOf(arrayType) + val arrays = inputs.mapIndexed { index, values -> + val array = state.memory.allocConcrete(descriptor) + state.memory.initializeArray( + arrayHeapRef = array, + type = descriptor, + sort = fp64Sort, + sizeSort = sizeSort, + contents = values.asSequence().map(::mkFp64), + ) + state.memory.write( + mkRegisterStackLValue(addressSort, index + 1), + array.asExpr(addressSort), + guard = trueExpr, + ) + state.saveSortForLocal(index + 1, addressSort) + array + } + arrays.forEach { array -> + state.markDenseInputArray(array = array, type = arrayType) + } + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "ArrayMutationEtsIr" } + .methods + .single { it.name == name } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: List, + val events: List, + ) { + fun singleNumber(): Double = assertIs(values.single()).number + + val modelIds: List + get() = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index 2a844e606..c2dd1c7aa 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -39,7 +39,13 @@ class TsArrayShiftReplayTest { DynamicTest.dynamicTest(case.name) { val method = methods.getValue("case$index") - val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions()).use { machine -> + val dispatcher = TsCompatibilityUnknownCallDispatcher.takeIf { case.compatibility } + val tests = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + unknownCallDispatcher = dispatcher, + ).use { machine -> machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } } @@ -154,7 +160,7 @@ class TsArrayShiftReplayTest { private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> copyCases(type) + mutationCases(type) + concatCases(type) - } + }.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } private fun copyCases(type: String): List = listOf( ReplayCase( @@ -464,6 +470,7 @@ class TsArrayShiftReplayTest { val parameters: String, val maxResult: Int, val body: String, + val compatibility: Boolean = false, ) private companion object { diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt index a38ba9b3f..4b6f4f2ad 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsInstanceCallReceiverTest.kt @@ -36,7 +36,8 @@ class TsInstanceCallReceiverTest { val methods = scene.projectClasses.single { it.name == "InstanceCallReceiver" }.methods.associateBy { it.name } return cases.map { case -> - DynamicTest.dynamicTest(case.method) { + val label = if (case.compatibility) "compatibility receiver: ${case.method}" else case.method + DynamicTest.dynamicTest(label) { val method = methods.getValue(case.method) val events = mutableListOf() val observer = object : TsInterpreterObserver { @@ -45,7 +46,13 @@ class TsInstanceCallReceiverTest { } } - val tests = TsMachine(scene, options = machineOptions, tsOptions = TsOptions(), observer = observer) + val tests = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + unknownCallDispatcher = TsCompatibilityUnknownCallDispatcher.takeIf { case.compatibility }, + ) .use { machine -> machine.analyze(listOf(method)).map { state -> TsTestResolver().resolve(method, state) } } @@ -125,30 +132,31 @@ class TsInstanceCallReceiverTest { val method: String, val results: Set, val throws: Boolean = false, + val compatibility: Boolean = false, ) private companion object { val cases = listOf( Case(method = "wrappedShift", results = setOf(1.0)), Case(method = "wrappedPop", results = setOf(1.0)), - Case(method = "wrappedPush", results = setOf(1.0)), - Case(method = "wrappedReverse", results = setOf(1.0)), - Case(method = "wrappedFill", results = setOf(1.0)), - Case(method = "wrappedUnshift", results = setOf(1.0)), - Case(method = "wrappedSlice", results = setOf(1.0)), - Case(method = "wrappedSliceReversed", results = setOf(1.0)), - Case(method = "wrappedSlicePastEnd", results = setOf(1.0)), - Case(method = "wrappedSlicePastStart", results = setOf(1.0)), - Case(method = "wrappedSliceNegative", results = setOf(1.0)), - Case(method = "wrappedSliceEmpty", results = setOf(1.0)), - Case(method = "wrappedConcat", results = setOf(1.0)), + Case(method = "wrappedPush", results = setOf(1.0), compatibility = true), + Case(method = "wrappedReverse", results = setOf(1.0), compatibility = true), + Case(method = "wrappedFill", results = setOf(1.0), compatibility = true), + Case(method = "wrappedUnshift", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlice", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceReversed", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlicePastEnd", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSlicePastStart", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceNegative", results = setOf(1.0), compatibility = true), + Case(method = "wrappedSliceEmpty", results = setOf(1.0), compatibility = true), + Case(method = "wrappedConcat", results = setOf(1.0), compatibility = true), Case(method = "wrappedUserMethod", results = setOf(1.0)), Case(method = "customShift", results = setOf(1.0)), Case(method = "conditionalArrays", results = setOf(0.0, 1.0)), Case(method = "conditionalEmptyArray", results = setOf(0.0, 1.0)), Case(method = "arrayOrUserMethod", results = setOf(0.0, 1.0)), Case(method = "primitiveValueOf", results = setOf(0.0, 1.0)), - Case(method = "primitiveToString", results = setOf(0.0, 1.0)), + Case(method = "primitiveToString", results = setOf(0.0, 1.0), compatibility = true), Case(method = "constrainedFake", results = setOf(0.0, 1.0, 2.0)), Case(method = "nullableReceiver", results = setOf(0.0, 1.0), throws = true), Case(method = "undefinedReceiver", results = setOf(0.0, 1.0), throws = true), diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt index a0152c12b..55730d005 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsSequenceEtsIrModelTest.kt @@ -211,11 +211,28 @@ class TsSequenceEtsIrModelTest { } @Test - fun `symbolic charAt falls back until string value equality is modeled`() { - val result = analyze(methodName = "symbolicCharAt") + fun `symbolic charAt preserves selected UTF16 content`() { + val result = analyze(methodName = "symbolicCharAtMatches") - assertTrue(result.values.isEmpty()) - assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + assertTrue(result.values.isNotEmpty()) + assertTrue(result.values.all { assertIs(it).value }) + assertTrue("ts.string.charAt" in result.modelIds) + } + + @Test + fun `substring swaps clamped bounds and preserves surrogate code units`() { + val result = analyze(methodName = "stringSubstringBounds") + + assertTrue(assertIs(result.values.single()).value) + assertTrue("ts.string.substring" in result.modelIds) + } + + @Test + fun `trim recognizes ECMAScript whitespace and preserves interior and non whitespace`() { + val result = analyze(methodName = "stringTrimWhitespace") + + assertTrue(assertIs(result.values.single()).value) + assertTrue(setOf("ts.string.trim", "ts.string.trimStart", "ts.string.trimEnd").all(result.modelIds::contains)) } @Test diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt new file mode 100644 index 000000000..667ba6965 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/GlobalArrayAssignmentBoundaryTest.kt @@ -0,0 +1,72 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +class GlobalArrayAssignmentBoundaryTest { + @Test + fun `fractional global array assignment stops as a named property limitation`() { + val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/GlobalArrayAssignmentBoundary.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + val scene = EtsScene(listOf(sourceFile)) + val method = scene.projectClasses + .single { it.name == "GlobalArrayAssignmentBoundary" } + .methods + .single { it.name == "readSecondValue" } + val observer = RecordingObserver() + + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 16), + observer = observer, + ).use { machine -> + machine.analyze(listOf(method)) + } + + assertTrue(states.isEmpty()) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + observer.limitations.single().reason, + ) + } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt new file mode 100644 index 000000000..848683b70 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt @@ -0,0 +1,216 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason +import org.usvm.machine.state.TsMethodResult +import org.usvm.machine.state.TsState +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class NumericBoundarySemanticsTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/NumericBoundarySemantics.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `bitwise operators use total ECMAScript int32 conversion`() { + val expectedResults = mapOf( + "bitwiseNaN" to 0.0, + "bitwiseInfinity" to 0.0, + "bitwiseUint32Wrap" to 1.0, + "bitwiseFraction" to 1.0, + "bitwiseNegativeFraction" to -1.0, + "bitwiseNotNaN" to -1.0, + "bitwiseAndInfinity" to 0.0, + "bitwiseXorInfinity" to 7.0, + "leftShiftMasksCount" to 2.0, + "rightShiftTruncates" to -2.0, + "unsignedRightShift" to 4_294_967_295.0, + "bitwiseNegativeZero" to 0.0, + ) + + expectedResults.forEach { (methodName, expected) -> + assertEquals(expected, singleNumber(methodName), methodName) + } + } + + @Test + fun `missing numeric array properties return undefined without integer coercion`() { + assertEquals(63.0, singleNumber("missingNumericArrayProperties")) + assertEquals(11.0, singleNumber("negativeZeroArrayIndex")) + } + + @Test + fun `unsupported named property and growth writes stop their paths`() { + val unsupportedMethods = mapOf( + "fractionalArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "negativeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "outOfRangeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + "chainedMissingArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + ) + + unsupportedMethods.forEach { (methodName, reason) -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals(reason, observer.limitations.single().reason, methodName) + } + assertEquals(7.0, singleNumber("negativeZeroArrayWrite")) + } + + @Test + fun `chained array indexes preserve numeric and undefined branches`() { + assertEquals(22.0, singleNumber("chainedArrayRead")) + assertIs(singleValue("chainedMissingArrayRead")) + } + + @Test + fun `reference property keys remain explicit read limitations`() { + val unsupportedMethods = listOf( + "stringArrayIndexRead", + "objectArrayIndexRead", + ) + + unsupportedMethods.forEach { methodName -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_READ, + observer.limitations.single().reason, + methodName, + ) + } + } + + @Test + fun `numeric string indexes address UTF16 code units without coercion`() { + assertEquals("A", singleString("stringZeroIndex")) + assertEquals("A", singleString("stringNegativeZeroIndex")) + assertEquals("\uD83D", singleString("stringHighSurrogateIndex")) + assertEquals("\uDE00", singleString("stringLowSurrogateIndex")) + assertEquals(31.0, singleNumber("missingNumericStringProperties")) + } + + @Test + fun `invalid array lengths throw before conversion`() { + val invalidMethods = listOf( + "newArrayInfinity", + "newArrayUint32Overflow", + "newArrayFraction", + "assignInvalidLength", + ) + + invalidMethods.forEach { methodName -> + val state = analyze(methodName).single() + assertIs(state.methodResult, methodName) + } + } + + @Test + fun `valid but unsupported lengths remain residual`() { + val unsupportedMethods = listOf( + "newArrayBeyondModelCapacity", + "assignLengthBeyondModelCapacity", + ) + + unsupportedMethods.forEach { methodName -> + val observer = RecordingObserver() + + assertTrue(analyze(methodName, observer).isEmpty(), methodName) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_LENGTH_CAPACITY, + observer.limitations.single().reason, + methodName, + ) + } + } + + @Test + fun `negative zero is a valid array length`() { + assertEquals(0.0, singleNumber("newArrayNegativeZero")) + assertEquals(0.0, singleNumber("assignNegativeZeroLength")) + } + + private fun singleNumber(methodName: String): Double { + val result = singleValue(methodName) + + return assertIs(result).number + } + + private fun singleString(methodName: String): String { + val result = singleValue(methodName) + + return assertIs(result).value + } + + private fun singleValue(methodName: String): TsTestValue { + val method = method(methodName) + val state = analyze(method).single() + + return TsTestResolver().resolve(method, state).returnValue + } + + private fun analyze( + methodName: String, + observer: TsInterpreterObserver? = null, + ): List = analyze(method(methodName), observer) + + private fun analyze( + method: EtsMethod, + observer: TsInterpreterObserver? = null, + ): List = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(maxArraySize = 16), + observer = observer, + ).use { machine -> + machine.analyze(listOf(method)) + } + + private fun method(name: String): EtsMethod = scene.projectClasses + .single { it.name == "NumericBoundarySemantics" } + .methods + .single { it.name == name } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt index 3c9c3feee..d828df3a4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/AllocatedArrays.kt @@ -2,11 +2,19 @@ package org.usvm.samples.arrays import org.jacodb.ets.model.EtsScene import org.junit.jupiter.api.Test +import org.usvm.UMachineOptions import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.TsRuntimeFeatureLimitationEvent +import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.test.util.checkers.noResultsExpected import org.usvm.util.TsMethodTestRunner import org.usvm.util.eq import org.usvm.util.neq +import kotlin.test.assertEquals +import kotlin.test.assertTrue class AllocatedArrays : TsMethodTestRunner() { private val tsPath = "/samples/arrays/AllocatedArrays.ts" @@ -95,19 +103,37 @@ class AllocatedArrays : TsMethodTestRunner() { @Test fun `test allocatedArrayLengthExpansion`() { - val method = getMethod("allocatedArrayLengthExpansion") - discoverProperties( - method = method, - { r -> r is TsTestValue.TsException } - ) + assertArrayIndexGrowthLimitation("allocatedArrayLengthExpansion") } @Test fun `test writeInTheIndexEqualToLength`() { - val method = getMethod("writeInTheIndexEqualToLength") - discoverProperties( - method = method, - { r -> r is TsTestValue.TsException }, + assertArrayIndexGrowthLimitation("writeInTheIndexEqualToLength") + } + + private fun assertArrayIndexGrowthLimitation(methodName: String) { + val observer = RecordingObserver() + val states = TsMachine( + scene = scene, + options = UMachineOptions(), + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + machine.analyze(listOf(getMethod(methodName))) + } + + assertTrue(states.isEmpty()) + assertEquals( + TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, + observer.limitations.single().reason, ) } + + private class RecordingObserver : TsInterpreterObserver { + val limitations = mutableListOf() + + override fun onRuntimeFeatureLimitation(event: TsRuntimeFeatureLimitationEvent) { + limitations += event + } + } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt index 31b1d18b6..486ec151b 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/samples/arrays/InputArrays.kt @@ -18,22 +18,22 @@ class InputArrays : TsMethodTestRunner() { val method = getMethod("inputArrayOfNumbers") discoverProperties, TsTestValue>( method = method, - { _, r -> r is TsTestValue.TsException }, { x, r -> r as TsTestValue.TsNumber - val x0 = x.values[0] as TsTestValue.TsNumber - (r eq 1) && (x0 eq 1) + val firstElement = x.values.firstOrNull() + (r eq 1) && firstElement is TsTestValue.TsNumber && (firstElement eq 1) }, { x, r -> r as TsTestValue.TsNumber - val x0 = x.values[0] as TsTestValue.TsNumber - (r eq 2) && (x0 neq 1) + val firstElement = x.values.firstOrNull() + val firstElementIsUndefined = firstElement == null || firstElement is TsTestValue.TsUndefined + (r eq -1) && firstElementIsUndefined + }, + { x, r -> + r as TsTestValue.TsNumber + val firstElement = x.values.firstOrNull() + (r eq 2) && firstElement is TsTestValue.TsNumber && (firstElement neq 1) }, - invariants = arrayOf( - { _, r -> - r !is TsTestValue.TsNumber || (r neq -1) - } - ) ) } @@ -62,7 +62,7 @@ class InputArrays : TsMethodTestRunner() { discoverProperties, TsTestValue.TsNumber>( method = method, { x, r -> (r eq 1) && x.values[0].value }, - { x, r -> (r eq -1) && !x.values[0].value }, + { x, r -> (r eq -1) && x.values.firstOrNull()?.value != true }, ) } @@ -73,29 +73,33 @@ class InputArrays : TsMethodTestRunner() { method = method, // TODO exception { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() (r.values == x.values) && firstElement is TsTestValue.TsNumber && (firstElement eq 1.1) }, { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() val firstElementCondition = firstElement !is TsTestValue.TsNumber || (firstElement neq 1.1) - val secondElement = x.values[1] + val secondElement = x.values.getOrNull(1) val secondElementCondition = secondElement is TsTestValue.TsBoolean && secondElement.value (r.values == x.values) && firstElementCondition && secondElementCondition }, { x, r -> - val firstElement = x.values[0] + val firstElement = x.values.firstOrNull() val firstElementCondition = firstElement !is TsTestValue.TsNumber || (firstElement neq 1.1) - val secondElement = x.values[1] + val secondElement = x.values.getOrNull(1) val secondElementCondition = secondElement !is TsTestValue.TsBoolean || !secondElement.value - val thirdElement = x.values[2] - val thirdElementCondition = thirdElement is TsTestValue.TsUndefined + val thirdElement = x.values.getOrNull(2) + val thirdElementCondition = thirdElement == null || thirdElement is TsTestValue.TsUndefined + val resultMatches = r.values.size == x.values.size && r.values.zip(x.values).all { (actual, expected) -> + actual == expected || + actual is TsTestValue.TsClass && expected is TsTestValue.TsClass && actual.name == expected.name + } - (r.values == x.values) && firstElementCondition && secondElementCondition && thirdElementCondition + resultMatches && firstElementCondition && secondElementCondition && thirdElementCondition } ) } @@ -127,8 +131,11 @@ class InputArrays : TsMethodTestRunner() { r is TsTestValue.TsNull && value is TsTestValue.TsNumber && (value eq 1) }, { x, r -> - val value = x.values[0] - (r == value) && (value !is TsTestValue.TsNumber || (value neq 1)) + val value = x.values.firstOrNull() + val valueIsNotOne = value !is TsTestValue.TsNumber || (value neq 1) + val resultMatches = if (value == null) r is TsTestValue.TsUndefined else r == value + + valueIsNotOne && resultMatches }, ) } @@ -155,7 +162,7 @@ class InputArrays : TsMethodTestRunner() { resultCondition && fstCondition && sndCondition }, { x, y, r -> - val fst = x.values[0] + val fst = x.values.firstOrNull() val condition = fst !is TsTestValue.TsNumber || (fst neq 1) condition && r is TsTestValue.TsArray<*> && r.values == x.values }, diff --git a/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts new file mode 100644 index 000000000..eaa43d30f --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayMutationEtsIr.ts @@ -0,0 +1,59 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class ArrayMutationEtsIr { + pushSupportedArities(): number { + const values: number[] = []; + const zero = values.push(); + const one = values.push(4); + const three = values.push(5, 6, 7); + return zero * 10000 + one * 1000 + three * 100 + values[0] * 10 + values[3]; + } + + pushTooManyArguments(): number { + const values: number[] = []; + return values.push(1, 2, 3, 4); + } + + fillNegativeFractionAndInfinity(values: number[]): number { + values.fill(9, -2.8, Infinity); + return values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + fillNaNAndFraction(values: number[]): number { + values.fill(7, NaN, 1.9); + return values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + reverseDense(values: number[]): number { + const result = values.reverse(); + return result[0] * 1000 + result[1] * 100 + result[2] * 10 + result[3]; + } + + unshiftDense(values: number[]): number { + const length = values.unshift(7, 8); + return length * 10000 + values[0] * 1000 + values[1] * 100 + values[2] * 10 + values[3]; + } + + sliceDense(values: number[]): number { + const result = values.slice(-3.8, Infinity); + return result.length * 10000 + + result[0] * 1000 + + result[1] * 100 + + result[2] * 10 + + values.length; + } + + concatDense(values: number[], other: number[]): number { + const result = values.concat(other); + return result.length * 10000 + + result[0] * 1000 + + result[1] * 100 + + result[2] * 10 + + result[3]; + } + + sparseReverseUsesResidual(): number[] { + return new Array(2).reverse(); + } +} diff --git a/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts b/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts new file mode 100644 index 000000000..6dbbbce8f --- /dev/null +++ b/usvm-ts/src/test/resources/models/GlobalArrayAssignmentBoundary.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +const globalValues = [11, 22]; +globalValues[0.9] = 7; + +export class GlobalArrayAssignmentBoundary { + readSecondValue(): number { + return globalValues[1]; + } +} diff --git a/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts new file mode 100644 index 000000000..b25a26c40 --- /dev/null +++ b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts @@ -0,0 +1,187 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class NumericBoundarySemantics { + bitwiseNaN(): number { + return (0 / 0) >> 1; + } + + bitwiseInfinity(): number { + return (1 / 0) >> 1; + } + + bitwiseUint32Wrap(): number { + return 4294967297 | 0; + } + + bitwiseFraction(): number { + return 1.9 | 0; + } + + bitwiseNegativeFraction(): number { + return -1.9 | 0; + } + + bitwiseNotNaN(): number { + return ~(0 / 0); + } + + bitwiseAndInfinity(): number { + return (1 / 0) & 7; + } + + bitwiseXorInfinity(): number { + return (1 / 0) ^ 7; + } + + leftShiftMasksCount(): number { + return 1 << 33; + } + + rightShiftTruncates(): number { + return -3.9 >> 1; + } + + unsignedRightShift(): number { + return -1.9 >>> 0; + } + + bitwiseNegativeZero(): number { + return -0 | 0; + } + + missingNumericArrayProperties(): number { + const values = [11, 22]; + let result = 0; + if (values[0 / 0] === undefined) result += 1; + if (values[1 / 0] === undefined) result += 2; + if (values[0.9] === undefined) result += 4; + if (values[-1] === undefined) result += 8; + if (values[2] === undefined) result += 16; + if (values[4294967297] === undefined) result += 32; + return result; + } + + negativeZeroArrayIndex(): number { + const values = [11, 22]; + return values[-0]; + } + + fractionalArrayWrite(): number { + const values = [11, 22]; + values[0.9] = 7; + return values[1]; + } + + negativeArrayWrite(): number { + const values = [11, 22]; + values[-1] = 7; + return values[1]; + } + + outOfRangeArrayWrite(): number { + const values = [11, 22]; + values[2] = 7; + return values[1]; + } + + negativeZeroArrayWrite(): number { + const values = [11, 22]; + values[-0] = 7; + return values[0]; + } + + chainedArrayRead(): number { + const values = [11, 22]; + const indices = [0, 1]; + return values[indices[1]]; + } + + chainedMissingArrayRead() { + const values = [11, 22]; + const indices = [0, 1]; + return values[indices[2]]; + } + + chainedMissingArrayWrite(): number { + const values = [11, 22]; + const indices = [0, 1]; + values[indices[2]] = 7; + return values[0]; + } + + stringArrayIndexRead(): number { + const values = [11, 22]; + return values["1"]; + } + + objectArrayIndexRead() { + const values = [11, 22]; + return values[{}]; + } + + stringZeroIndex(): string { + return "A😀B"[0]; + } + + stringNegativeZeroIndex(): string { + return "A😀B"[-0]; + } + + stringHighSurrogateIndex(): string { + return "A😀B"[1]; + } + + stringLowSurrogateIndex(): string { + return "A😀B"[2]; + } + + missingNumericStringProperties(): number { + const value = "abc"; + let result = 0; + if (value[0.9] === undefined) result += 1; + if (value[0 / 0] === undefined) result += 2; + if (value[1 / 0] === undefined) result += 4; + if (value[4294967297] === undefined) result += 8; + if (value[3] === undefined) result += 16; + return result; + } + + newArrayInfinity(): number { + return new Array(1 / 0).length; + } + + newArrayUint32Overflow(): number { + return new Array(4294967296).length; + } + + newArrayFraction(): number { + return new Array(1.9).length; + } + + newArrayBeyondModelCapacity(): number { + return new Array(17).length; + } + + newArrayNegativeZero(): number { + return new Array(-0).length; + } + + assignInvalidLength(): number { + const values = [11, 22]; + values.length = 1 / 0; + return values.length; + } + + assignLengthBeyondModelCapacity(): number { + const values = [11, 22]; + values.length = 17; + return values.length; + } + + assignNegativeZeroLength(): number { + const values = [11, 22]; + values.length = -0; + return values.length; + } +} diff --git a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts index 9c6cbde6c..151ed2d73 100644 --- a/usvm-ts/src/test/resources/models/SequenceEtsIr.ts +++ b/usvm-ts/src/test/resources/models/SequenceEtsIr.ts @@ -111,6 +111,27 @@ export class SequenceEtsIr { return "abc".charAt(position); } + symbolicCharAtMatches(position: number): boolean { + if (position !== 0 && position !== 1) return true; + return "ab".charAt(position) === (position === 0 ? "a" : "b"); + } + + stringSubstringBounds(): boolean { + return "abc".substring(2, 1) === "b" + && "abc".substring(NaN, Infinity) === "abc" + && "abc".substring(-Infinity, -1) === "" + && "abc".substring(1.9, undefined) === "bc" + && "\ud83d\ude00".substring(1, 0).charCodeAt(0) === 0xd83d; + } + + stringTrimWhitespace(): boolean { + return "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff".trim() === "" + && " \ud83d\ude00 x \u00a0".trim() === "\ud83d\ude00 x" + && " \u0085\u180e\u200b ".trim() === "\u0085\u180e\u200b" + && " \tvalue \n".trimStart() === "value \n" + && " \tvalue \n".trimEnd() === " \tvalue"; + } + stringCharCodeAtHandlesBounds(): number { const outside = "AZ".charCodeAt(2); return "AZ".charCodeAt(1) + (outside !== outside ? 1 : 0); From 411b48b8dadba9acb245eef18fdeefdcf67912bd Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 22:46:39 +0300 Subject: [PATCH 31/33] Support Error and Array constructors, replacement, and erased assertions --- .../usvm/ts/calls/CallsIrReadinessIssue.kt | 31 ++++ .../ts/calls/CurrentTsCallsSymbolicEngine.kt | 1 + .../calls/CurrentTsCallsSymbolicEngineTest.kt | 98 +++++++++- .../PropertyEtsExportResolutionTest.kt | 2 +- usvm-ts/UNKNOWN_CALL_MODELS.md | 13 +- .../org/usvm/machine/TsInterpreterObserver.kt | 1 + .../call/intrinsic/TsArrayEtsIrModelFamily.kt | 120 ++++++++++++- .../call/intrinsic/TsErrorEtsIrModelFamily.kt | 82 +++++++++ .../intrinsic/TsStringEtsIrModelFamily.kt | 27 ++- .../usvm/machine/expr/CallApproximations.kt | 1 + .../kotlin/org/usvm/machine/expr/ReadField.kt | 53 ++++-- .../org/usvm/machine/expr/TsExprResolver.kt | 70 ++++---- .../org/usvm/machine/expr/WriteArray.kt | 25 ++- .../org/usvm/machine/expr/WriteField.kt | 11 +- .../usvm/machine/call/models/ArrayModels.ts | 5 + .../usvm/machine/call/models/ErrorModels.ts | 12 ++ .../usvm/machine/call/models/StringModels.ts | 60 +++++++ .../call/TsArrayConstructorEtsIrModelTest.kt | 170 ++++++++++++++++++ .../machine/call/TsArrayShiftReplayTest.kt | 56 ++++++ .../machine/call/TsErrorEtsIrModelTest.kt | 135 ++++++++++++++ .../call/TsStringReplaceAllEtsIrModelTest.kt | 96 ++++++++++ .../call/TsUnknownCallModelCatalogTest.kt | 2 + .../expr/NumericBoundarySemanticsTest.kt | 1 + .../resources/models/ArrayConstructorEtsIr.ts | 64 +++++++ .../src/test/resources/models/ErrorEtsIr.ts | 22 +++ .../models/NumericBoundarySemantics.ts | 7 + .../resources/models/StringReplaceAllEtsIr.ts | 34 ++++ .../resources/models/UserDefinedErrorEtsIr.ts | 14 ++ .../array-constructor-shadow/exported.ts | 3 + .../models/array-constructor-shadow/main.ts | 10 ++ 30 files changed, 1159 insertions(+), 67 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt create mode 100644 usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt create mode 100644 usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/ErrorEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts create mode 100644 usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts create mode 100644 usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt index c8a4771c8..e6e5b451c 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsIrReadinessIssue.kt @@ -2,6 +2,7 @@ package org.usvm.ts.calls import org.jacodb.ets.model.EtsAddExpr import org.jacodb.ets.model.EtsEntity +import org.jacodb.ets.model.EtsExpExpr import org.jacodb.ets.model.EtsFieldRef import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsFunctionType @@ -11,7 +12,11 @@ import org.jacodb.ets.model.EtsNumberConstant import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsRawEntity import org.jacodb.ets.model.EtsRawStmt +import org.jacodb.ets.model.EtsStaticFieldRef import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.STATIC_INIT_METHOD_NAME import org.jacodb.ets.utils.getDeclaredLocals import org.jacodb.ets.utils.getOperands import org.usvm.machine.TsGraph @@ -42,6 +47,8 @@ internal fun callsIrReadinessIssue( currentMethod.sameFileCallees(graph = graph, sourceFile = sourceFile) .forEach(pending::addLast) + currentMethod.sameFileStaticInitializers(graph = graph, sourceFile = sourceFile) + .forEach(pending::addLast) } return null @@ -58,6 +65,23 @@ private fun EtsMethod.sameFileCallees( } .filter { callee -> callee.signature.enclosingClass.file == sourceFile } +private fun EtsMethod.sameFileStaticInitializers( + graph: TsGraph, + sourceFile: EtsFileSignature, +): Sequence = cfg.stmts.asSequence() + .flatMap { it.walkEntities().asSequence() } + .filterIsInstance() + .map { it.field.enclosingClass } + .filter { it.file == sourceFile } + .distinct() + .mapNotNull { signature -> + val owner = graph.cp.projectClasses.singleOrNull { it.signature == signature } ?: return@mapNotNull null + owner.methods.singleOrNull { it.name == STATIC_INIT_METHOD_NAME } + ?: owner.takeIf { it.name == DEFAULT_ARK_CLASS_NAME } + ?.methods + ?.singleOrNull { it.name == DEFAULT_ARK_METHOD_NAME } + } + private fun EtsMethod.irReadinessIssue( source: String, admittedLexicalEnvironment: EtsLexicalEnvType?, @@ -104,6 +128,13 @@ private fun EtsMethod.irReadinessIssue( ) } + if (entities.any { it is EtsExpExpr }) { + return issue( + reasonCode = CallsSymbolicPreflightReasonCode.EXPONENTIATION_UNSUPPORTED, + diagnostic = "Reachable EtsIR contains unsupported exponentiation in $name", + ) + } + if (entities.any(::requiresSymbolicNumberToString)) { return issue( reasonCode = CallsSymbolicPreflightReasonCode.SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt index cebb59fd0..66c4d9a3b 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngine.kt @@ -63,6 +63,7 @@ internal enum class CallsSymbolicPreflightReasonCode { RAW_ENTITY_UNSUPPORTED, PROTOTYPE_ACCESS_UNSUPPORTED, SYMBOLIC_NUMBER_TO_STRING_UNSUPPORTED, + EXPONENTIATION_UNSUPPORTED, TARGET_ORIGIN_UNMAPPED, TARGET_ORIGIN_UNSUPPORTED, FRONTEND_OR_PREFLIGHT_ERROR, diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt index 90edabbb6..03bf910e9 100644 --- a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CurrentTsCallsSymbolicEngineTest.kt @@ -21,6 +21,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds +private const val ERROR_CONSTRUCTOR_MODEL_ID: String = "ts.error.constructor" + @Suppress("LargeClass") class CurrentTsCallsSymbolicEngineTest { @TempDir @@ -586,6 +588,51 @@ class CurrentTsCallsSymbolicEngineTest { ) } + @Test + fun `preflight rejects exponentiation before scheduling profiles`() { + val fixture = fixture( + source = """ + export function cubeRoot(value: number): boolean { + if (value ** (1 / 3) === 2) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "cubeRoot", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.EXPONENTIATION_UNSUPPORTED, result.reasonCode) + } + + @Test + fun `preflight checks a global regex initializer reached by a static field read`() { + val fixture = fixture( + source = """ + export const pattern = /abc/; + export function matches(value: string): boolean { + if (pattern.test(value)) { + return true; + } + return false; + } + """.trimIndent(), + exportName = "matches", + inputs = listOf(PropertyInput(name = "value", domain = StringDomain())), + targetStatement = "return true;", + ) + + val result = fixture.preflight() + + assertEquals(CallsSymbolicPreflightStatus.UNSUPPORTED, result.status) + assertEquals(CallsSymbolicPreflightReasonCode.REGEX_LITERAL_UNSUPPORTED, result.reasonCode) + } + @Test fun `initializes captured Math for modeled arrow search and replay`() { val fixture = fixture( @@ -724,6 +771,46 @@ class CurrentTsCallsSymbolicEngineTest { fixture.assertReplayConfirmed(inputs) } + @Test + fun `Error constructor model reaches throw and native replay preserves the exception`() { + val fixture = fixture( + source = """ + export function rejectsZero(value: number): boolean { + if (value === 0) { + throw new Error('expected message'); + } + return false; + } + """.trimIndent(), + exportName = "rejectsZero", + inputs = listOf(PropertyInput(name = "value", domain = NumberDomain())), + targetStatement = "throw new Error('expected message');", + ) + val unknownCalls = mutableListOf() + + val result = fixture.search( + modelIds = setOf(ERROR_CONSTRUCTOR_MODEL_ID), + unknownCallEventSink = unknownCalls::add, + ) + + val inputs = assertNotNull(result.inputs, "$result; unknownCalls=$unknownCalls") + assertTrue(assertIs(inputs.single()).toDouble() == 0.0) + assertEquals( + listOf(ERROR_CONSTRUCTOR_MODEL_ID), + unknownCalls.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + }, + unknownCalls.toString(), + ) + + val replay = fixture.replay(inputs) + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + assertEquals("threw", replay.invocation?.invocation) + assertEquals(true, replay.invocation?.targetHit) + assertEquals("Error", replay.invocation?.errorName) + assertEquals("expected message", replay.invocation?.errorMessage) + } + private fun fixture( source: String, exportName: String, @@ -836,15 +923,18 @@ class CurrentTsCallsSymbolicEngineTest { ) fun assertReplayConfirmed(inputs: List) { - val replay = OriginalTypeScriptTargetReplayer().replay( + val replay = replay(inputs) + + assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) + } + + fun replay(inputs: List): CallsSourceReplayResult = + OriginalTypeScriptTargetReplayer().replay( sourceRoots = listOf(sourceRoot), entryPoint = function.entryPoint, inputs = inputs, target = target, timeoutMillis = 10_000L, ) - - assertEquals(CallsReplayStatus.CONFIRMED, replay.status, replay.toString()) - } } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt index aaeb255b8..dd018f2a6 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/mapping/PropertyEtsExportResolutionTest.kt @@ -3,8 +3,8 @@ package org.usvm.ts.pbt.mapping import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFunctionType -import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsLexicalEnvType +import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsScene import org.jacodb.ets.model.EtsStaticFieldRef import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME diff --git a/usvm-ts/UNKNOWN_CALL_MODELS.md b/usvm-ts/UNKNOWN_CALL_MODELS.md index 5c88bc396..1c3888308 100644 --- a/usvm-ts/UNKNOWN_CALL_MODELS.md +++ b/usvm-ts/UNKNOWN_CALL_MODELS.md @@ -67,14 +67,17 @@ The built-in catalog includes the following public APIs and their internal stora | --- | --- | --- | | `ts.array.shift` | Kotlin intrinsic using symbolic-memory `memcpy` | Zero-argument `shift` on a definitely one-dimensional array. | | `ts.array.isArray` | Kotlin runtime-type primitive | The genuine global Array predicate, including null, undefined and fake-value wrappers. | +| `ts.array.fromLength` | TypeScript/EtsIR body with a heap-allocation primitive | Genuine callable `Array(length)` with one numeric argument. Valid lengths up to 16 allocate holes that read as `undefined`; larger valid lengths use fallback. | | `ts.array.pop` | TypeScript/EtsIR body | Zero-argument `pop` on a definitely one-dimensional array that also satisfies the symbolic runtime type guard. | | `ts.array.includes`, `ts.array.indexOf`, `ts.array.lastIndexOf` | TypeScript/EtsIR bodies | One-dimensional arrays and numeric positions, with the missing-slot exclusions below. | -| `ts.array.push`, `ts.array.fill`, `ts.array.reverse`, `ts.array.unshift`, `ts.array.slice`, `ts.array.concat` | TypeScript/EtsIR bodies with storage growth/allocation primitives | One-dimensional arrays with length/result at most 16. Push/unshift accept up to three arguments; resolved-sort arrays require matching element sorts, while mixed arrays retain runtime value kinds. Concat accepts one same-type array. Except push, these operations require a current dense-array proof. | +| `ts.array.push`, `ts.array.fill`, `ts.array.reverse`, `ts.array.unshift`, `ts.array.slice`, `ts.array.concat` | TypeScript/EtsIR bodies with storage growth/allocation primitives | One-dimensional arrays with length/result at most 16. Push/unshift accept up to three arguments; resolved-sort arrays require matching element sorts, while mixed arrays retain runtime value kinds. Concat accepts one same-type array. Except push and full-range `fill(value)`, these operations require a current dense-array proof. | | `ts.string.charAt`, `ts.string.charCodeAt`, `ts.string.includes`, `ts.string.indexOf`, `ts.string.lastIndexOf`, `ts.string.startsWith`, `ts.string.endsWith` | TypeScript/EtsIR bodies | Strings in initialized UTF-16 storage, including symbolic code units and numeric positions. | | `ts.string.slice`, `ts.string.substring`, `ts.string.trim`, `ts.string.trimStart`, `ts.string.trimEnd` | TypeScript/EtsIR bodies | UTF-16 range copying; substring clamps/swaps bounds, and trim uses the ECMAScript whitespace set. | +| `ts.string.replaceAll` | TypeScript/EtsIR body | String receiver, string search and string replacement; non-overlapping UTF-16 matches, empty search and ECMAScript dollar substitutions for a dollar sign, the match, its prefix and its suffix. RegExp, callbacks and argument coercions use fallback. | | `ts.string.toLowerCase`, `ts.string.toUpperCase` | TypeScript/EtsIR bodies | ASCII strings of at most 16 code units; other strings use residual fallback. | | `ts.math.abs`, `ts.math.ceil`, `ts.math.floor`, `ts.math.max`, `ts.math.min`, `ts.math.round`, `ts.math.sqrt`, `ts.math.trunc` | Kotlin FP primitives | Numeric arguments; dynamic coercions use fallback. | | `ts.number.isFinite`, `ts.number.isInteger`, `ts.number.isNaN`, `ts.number.isSafeInteger` | Kotlin FP/type primitives | Non-coercing Number predicates, including runtime-kind guards. | +| `ts.error.constructor` | TypeScript/EtsIR body | Genuine `new Error(message)` with one string argument; initializes `name` and `message`. Other arities, coercions, subclasses, `cause` and stack inspection are outside this model. | | `ts.date.*` (38 IDs) | TypeScript/EtsIR bodies | Numeric Date construction, `UTC`, fixed-clock `now`, getters, setters, `valueOf`, and source `toISOString`; see the Date boundary below. | Matching standard calls are assumed to refer to genuine builtins. Monkey patching and prototype replacement are @@ -102,14 +105,18 @@ modeled array methods can still use fallback after its first mutation or copy. ### Runtime limitations and experimental outcomes `TsInterpreterObserver.onRuntimeFeatureLimitation` records feasible paths stopped by bounded array storage, such as -named-property writes or unsupported length growth. These events are separate from unknown-call model decisions. +named-property access, unsupported length growth, or assigning a runtime kind absent from a typed array's storage. +Reads may return an element or `undefined`; subsequent numeric operations and typed writes preserve that runtime-kind +guard. TypeScript `as` and angle-bracket assertions are erased and never change a value or constrain its runtime kind. +These events are separate from unknown-call model decisions. The Calls runner writes them synchronously and reports `RUNTIME_LIMITATION` when search exhausts after such a stop without reaching the target. An actual timeout remains `TIMEOUT`; a reached target still requires original-source replay. Calls preflight checks input binding, source/IR target mapping and known unsupported IR features before choosing a model/fallback profile. Excluded functions remain in the corpus support ledger. Comparative runs use a frozen common target set; failures discovered after that freeze remain scheduled failures. The analysis budget is 30 seconds per -target and seed; JVM/frontend startup and original-source replay have separate recorded limits. +target, profile and seed; JVM/frontend startup and original-source replay have separate recorded limits. Readiness +traverses reachable same-file callees and the initializers triggered by their static-field accesses. ### `unknownCallFallback` diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt index 3045eb253..413136a4a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/TsInterpreterObserver.kt @@ -93,6 +93,7 @@ enum class TsRuntimeFeatureLimitationReason { ARRAY_NAMED_PROPERTY_READ, ARRAY_NAMED_PROPERTY_WRITE, ARRAY_INDEX_GROWTH, + ARRAY_ELEMENT_KIND_WRITE, ARRAY_LENGTH_CAPACITY, ARRAY_LENGTH_GROWTH, } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt index 05eef09bd..9bde8deff 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsArrayEtsIrModelFamily.kt @@ -2,11 +2,16 @@ package org.usvm.machine.call.intrinsic import io.ksmt.utils.asExpr import io.ksmt.utils.cast +import org.jacodb.ets.model.EtsAnyType import org.jacodb.ets.model.EtsArrayType +import org.jacodb.ets.model.EtsFunctionType +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsUnknownType import org.usvm.UBoolExpr import org.usvm.UConcreteHeapRef import org.usvm.UExpr +import org.usvm.api.initializeArray import org.usvm.api.initializeArrayLength import org.usvm.machine.call.TsEtsIrUnknownCallModel import org.usvm.machine.call.TsEtsIrUnknownCallModelArtifact @@ -21,9 +26,13 @@ import org.usvm.machine.call.TsUnknownCallModelSuccessor import org.usvm.machine.call.TsUnknownCallTarget import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact import org.usvm.machine.expr.TsUnresolvedSort +import org.usvm.machine.expr.mkFpToUint32AfterValidation +import org.usvm.machine.expr.mkValidArrayLength import org.usvm.machine.state.TsState +import org.usvm.machine.types.TsUnresolvedArrayKind import org.usvm.sizeSort import org.usvm.util.arrayStorageType +import org.usvm.util.initializeArrayKind import org.usvm.util.isUnmodifiedDenseInputArray import org.usvm.util.mkArrayLengthLValue @@ -116,8 +125,9 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { val value = call.arguments.firstOrNull()?.resolved ?: return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr val valueMatchesArrayType = state.argumentsMatchArrayType(arrayType, listOf(value)) - val isDenseInput = state.isUnmodifiedDenseInputArray(array, arrayType) - if (!valueMatchesArrayType || !isDenseInput) { + val fillsWholeArray = call.arguments.size == 1 + val hasKnownSlotValues = fillsWholeArray || state.isUnmodifiedDenseInputArray(array, arrayType) + if (!valueMatchesArrayType || !hasKnownSlotValues) { return@TsEtsIrUnknownCallModelDomainGuard state.ctx.falseExpr } @@ -282,8 +292,31 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { if (call.arguments.isEmpty()) call.resolvedInstanceInputs() else null } + private val arrayConstructorAdapter = TsEtsIrUnknownCallModelInputAdapter { state, call -> + if (!call.hasGlobalArrayConstructorShape() || call.arguments.size != 1) { + return@TsEtsIrUnknownCallModelInputAdapter null + } + + val length = call.arguments.single().resolved + ?.takeIf { value -> value.sort == state.ctx.fp64Sort } + ?: return@TsEtsIrUnknownCallModelInputAdapter null + listOf(length) + } + override val models: List by lazy { + val arrayConstructorModel = TsEtsIrUnknownCallModel( + id = ARRAY_FROM_LENGTH_ID, + target = TsUnknownCallTarget( + methodName = "Array", + failureReason = TsUnknownCallFailureReason.POINTER_TARGET_NOT_FOUND, + ), + artifact = artifact("fromLength"), + inputAdapter = arrayConstructorAdapter, + requiredModelIds = setOf(PRIMITIVE_ALLOCATE_ID), + ) + listOf( + arrayConstructorModel, sourceModel( id = "ts.array.pop", methodName = "pop", @@ -343,6 +376,11 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { arity = 2, implementation = ::growArray, ), + primitiveModel( + methodName = "allocate", + arity = 1, + implementation = ::allocateArray, + ), primitiveModel( methodName = "allocateLike", arity = 2, @@ -492,6 +530,60 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { ) } + private fun allocateArray( + state: TsState, + inputs: List>, + ): TsUnknownCallModelExecution? = with(state.ctx) { + val fpLength = inputs.singleOrNull() + ?.takeIf { value -> value.sort == fp64Sort } + ?.asExpr(fp64Sort) + ?: return null + val validLength = mkValidArrayLength(fpLength) + val maximumLength = mkFp64(MAX_SOURCE_ARRAY_LENGTH.toDouble()) + val withinModelCapacity = mkFpLessOrEqualExpr( + fpLength, + maximumLength, + ) + val supportedLength = mkAnd(validLength, withinModelCapacity) + val length = mkFpToUint32AfterValidation(fpLength, validLength).asExpr(sizeSort) + val arrayType = EtsArrayType(EtsUnknownType, dimensions = 1) + + // The model covers only valid, bounded one-number lengths. All other inputs remain residual. + val validSuccessor = TsUnknownCallModelSuccessor( + guard = supportedLength, + completion = TsUnknownCallModelCompletion.Normal { + val descriptor = arrayDescriptorOf(arrayType) + val result = memory.allocConcrete(descriptor) + val undefinedSlots = List(MAX_SOURCE_ARRAY_LENGTH) { mkUndefinedValue() } + memory.initializeArray( + arrayHeapRef = result, + type = descriptor, + sort = addressSort, + sizeSort = sizeSort, + contents = undefinedSlots.asSequence(), + ) + TsUnresolvedArrayKind.entries.forEach { kind -> + initializeArrayKind( + array = result, + kind = kind, + values = List(MAX_SOURCE_ARRAY_LENGTH) { falseExpr }, + ) + } + memory.initializeArrayLength( + arrayHeapRef = result, + type = descriptor, + sizeSort = sizeSort, + count = length, + ) + result + }, + ) + TsUnknownCallModelExecution( + successors = listOf(validSuccessor), + residualGuard = mkNot(supportedLength), + ) + } + private fun allocateArrayLike( state: TsState, inputs: List>, @@ -544,6 +636,27 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { return listOf(resolvedReceiver) + resolvedArguments } + private fun TsUnknownCall.hasGlobalArrayConstructorShape(): Boolean { + val owner = receiver?.source as? EtsLocal ?: return false + val ownerSignature = (owner.type as? EtsFunctionType)?.signature ?: return false + val ownerParameter = ownerSignature.parameters.singleOrNull() ?: return false + val ownerReturnType = ownerSignature.returnType as? EtsArrayType ?: return false + val callReturnType = resultType as? EtsArrayType ?: return false + val signatureFile = ownerSignature.enclosingClass.file + + return owner.name == "Array" && + signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT && + ownerSignature.name.isEmpty() && + ownerParameter.type == EtsNumberType && + ownerParameter.isOptional && + !ownerParameter.isRest && + ownerReturnType.elementType == EtsAnyType && + ownerReturnType.dimensions == 1 && + callReturnType.elementType == EtsAnyType && + callReturnType.dimensions == 1 + } + private class ArrayPrimitiveModel( methodName: String, private val arity: Int, @@ -567,6 +680,9 @@ internal object TsArrayEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } private const val MATH_FLOOR_MODEL_ID = "ts.math.floor" + private const val ARRAY_FROM_LENGTH_ID = "ts.array.fromLength" private const val PRIMITIVE_GROW_ID = "ts.array.primitive.grow" + private const val PRIMITIVE_ALLOCATE_ID = "ts.array.primitive.allocate" private const val PRIMITIVE_ALLOCATE_LIKE_ID = "ts.array.primitive.allocateLike" + private const val UNKNOWN_SIGNATURE_COMPONENT = "%unk" } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt new file mode 100644 index 000000000..6da50ef18 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt @@ -0,0 +1,82 @@ +package org.usvm.machine.call.intrinsic + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType +import org.jacodb.ets.model.EtsStringType +import org.jacodb.ets.utils.CONSTRUCTOR_NAME +import org.usvm.UConcreteHeapRef +import org.usvm.api.evalTypeEquals +import org.usvm.api.typeStreamOf +import org.usvm.machine.call.TsEtsIrUnknownCallModel +import org.usvm.machine.call.TsEtsIrUnknownCallModelDomainGuard +import org.usvm.machine.call.TsEtsIrUnknownCallModelInputAdapter +import org.usvm.machine.call.TsUnknownCallFailureReason +import org.usvm.machine.call.TsUnknownCallModel +import org.usvm.machine.call.TsUnknownCallTarget +import org.usvm.machine.call.loadBundledEtsIrUnknownCallModelArtifact +import org.usvm.types.single + +/** Narrow built-in `Error(message: string)` constructor model. */ +internal object TsErrorEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { + const val CONSTRUCTOR_ID: String = "ts.error.constructor" + + private const val ERROR_CLASS = "Error" + private const val RESOURCE = "/org/usvm/machine/call/models/ErrorModels.ts" + + private val artifact by lazy { + loadBundledEtsIrUnknownCallModelArtifact( + resourceName = RESOURCE, + sourceFileName = "ErrorModels.ts", + entryPointClassName = "ErrorModels", + entryPointMethodName = "construct", + ) + } + + override val models: List by lazy { + listOf(constructorModel()) + } + + private fun constructorModel(): TsUnknownCallModel { + val target = TsUnknownCallTarget( + methodName = CONSTRUCTOR_NAME, + enclosingClassName = ERROR_CLASS, + failureReason = TsUnknownCallFailureReason.RECEIVER_CLASS_NOT_FOUND, + ) + + return TsEtsIrUnknownCallModel( + id = CONSTRUCTOR_ID, + target = target, + artifact = artifact, + domainGuard = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> + with(state.ctx) { + val receiver = inputs.getOrNull(0) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val message = inputs.getOrNull(1) as? UConcreteHeapRef + ?: return@TsEtsIrUnknownCallModelDomainGuard falseExpr + val builtinSignature = EtsClassSignature.UNKNOWN.copy(name = ERROR_CLASS) + val builtinErrorType = EtsClassType(signature = builtinSignature) + val receiverRuntimeType = state.memory.typeStreamOf(receiver).single() + + if (call.callee.enclosingClass != builtinSignature) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + if (receiver.hasFakeValueBranch() || receiverRuntimeType != builtinErrorType) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + if (message.hasFakeValueBranch()) { + return@TsEtsIrUnknownCallModelDomainGuard falseExpr + } + + state.memory.types.evalTypeEquals(message, EtsStringType) + } + }, + inputAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + val receiver = call.receiver?.resolved ?: return@TsEtsIrUnknownCallModelInputAdapter null + val message = call.arguments.singleOrNull()?.resolved + ?: return@TsEtsIrUnknownCallModelInputAdapter null + + listOf(receiver, message) + }, + ) + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 4dbec10f5..33d21423a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -34,6 +34,7 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private const val CLASS_NAME = "StringModels" private const val PRIMITIVES_CLASS_NAME = "StringModelPrimitives" private const val RESOURCE_NAME = "/org/usvm/machine/call/models/StringModels.ts" + private const val REPLACE_ALL_INPUT_COUNT = 3 private val characterArrayType = EtsArrayType(EtsNumberType, dimensions = 1) @@ -167,6 +168,22 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { } } + private val replaceAllAdapter = TsEtsIrUnknownCallModelInputAdapter { _, call -> + if (call.arguments.size == 2) call.resolvedInstanceInputs() else null + } + + private val replaceAllDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> + with(state.ctx) { + val strings = inputs.filterIsInstance() + if (strings.size != REPLACE_ALL_INPUT_COUNT || strings.any { it.hasFakeValueBranch() }) { + falseExpr + } else { + val guards = strings.map { state.memory.types.evalTypeEquals(it, EtsStringType) } + mkAnd(guards) + } + } + } + private val asciiReceiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, call, inputs -> with(state.ctx) { val receiverGuard = receiverDomain.evaluate(state, call, inputs) @@ -260,6 +277,12 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { inputAdapter = sliceAdapter, domainGuard = receiverDomain, ), + sourceModel( + id = "ts.string.replaceAll", + methodName = "replaceAll", + inputAdapter = replaceAllAdapter, + domainGuard = replaceAllDomain, + ), sourceModel( id = "ts.string.trim", methodName = "trim", @@ -332,10 +355,10 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { add(MATH_FLOOR_MODEL_ID) add(PRIMITIVE_LENGTH_ID) add(PRIMITIVE_CODE_UNIT_AT_ID) - if (methodName in setOf("charAt", "toUpperCase", "toLowerCase")) { + if (methodName in setOf("charAt", "toUpperCase", "toLowerCase", "replaceAll")) { add(PRIMITIVE_FROM_CODE_UNIT_ID) } - if (methodName in setOf("slice", "substring", "trim", "trimStart", "trimEnd")) { + if (methodName in setOf("slice", "substring", "trim", "trimStart", "trimEnd", "replaceAll")) { add(PRIMITIVE_COPY_RANGE_ID) } }, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt index 83e1f7d62..255905be1 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt @@ -227,6 +227,7 @@ internal fun TsExprResolver.tryApproximateInstanceCall( } val modeledStringMethods = setOf( + "replaceAll", "substring", "trim", "trimStart", diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt index 8e6deace5..8f35be8d6 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt @@ -2,17 +2,22 @@ package org.usvm.machine.expr import io.ksmt.utils.asExpr import mu.KotlinLogging +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsStaticFieldRef +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.api.typeStreamOf import org.usvm.machine.TsContext import org.usvm.machine.interpreter.TsStepScope import org.usvm.machine.interpreter.ensureStaticsInitialized import org.usvm.machine.types.EtsAuxiliaryType import org.usvm.machine.types.mkFakeValue +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.TsResolutionResult import org.usvm.util.createFakeField @@ -65,16 +70,34 @@ fun TsContext.readField( ): UExpr<*>? { checkNotFake(instance) - val sort = when (val etsField = resolveEtsField(instanceLocal, field, hierarchy)) { + val isUnresolvedErrorField = field.isUnresolvedErrorField() + val isErrorModelStorageField = isUnresolvedErrorField && instance is UConcreteHeapRef && scope.calcOnState { + memory.typeStreamOf(instance).singleOrNull() == EtsClassType( + signature = EtsClassSignature.UNKNOWN.copy(name = "Error"), + ) + } + val etsField = when { + isErrorModelStorageField -> TsResolutionResult.Empty + isUnresolvedErrorField -> resolveEtsField( + instance = instanceLocal, + field = field.copy(enclosingClass = EtsClassSignature.UNKNOWN), + hierarchy = hierarchy, + ) + else -> resolveEtsField(instanceLocal, field, hierarchy) + } + val isModelStorageField = field.isModelStorageField() || isErrorModelStorageField + val sort = when (etsField) { is TsResolutionResult.Empty -> { - if (field.name !in listOf("i", "LogLevel")) { - logger.warn { "Field $field not found, creating fake field" } + if (!isModelStorageField) { + if (field.name !in listOf("i", "LogLevel")) { + logger.warn { "Field $field not found, creating fake field" } + } + // If we didn't find any real fields, let's create a fake one. + // It is possible due to mistakes in the IR or if the field was added explicitly + // in the code. + // Probably, the right behaviour here is to fork the state. + instance.createFakeField(scope, field.name) } - // If we didn't find any real fields, let's create a fake one. - // It is possible due to mistakes in the IR or if the field was added explicitly - // in the code. - // Probably, the right behaviour here is to fork the state. - instance.createFakeField(scope, field.name) addressSort } @@ -83,7 +106,7 @@ fun TsContext.readField( is TsResolutionResult.Ambiguous -> unresolvedSort } - if (!field.isDateModelTimestamp()) { + if (!isModelStorageField) { val fieldExists = scope.calcOnState { // If we accessed some field, we make an assumption that // this field should present in the object. @@ -124,8 +147,16 @@ fun TsContext.readField( } } -private fun EtsFieldSignature.isDateModelTimestamp(): Boolean = - enclosingClass.name == "DateValue" && name == "timestamp" +private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { + "DateValue" -> name == "timestamp" + "ErrorValue" -> { + enclosingClass.file.fileName == "ErrorModels.ts" && (name == "name" || name == "message") + } + else -> false +} + +private fun EtsFieldSignature.isUnresolvedErrorField(): Boolean = + enclosingClass == EtsClassSignature.UNKNOWN.copy(name = "Error") && (name == "name" || name == "message") internal fun TsExprResolver.handleStaticFieldRef( value: EtsStaticFieldRef, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt index c986e81e1..52397ba30 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/TsExprResolver.kt @@ -144,6 +144,7 @@ private const val ECMASCRIPT_BITWISE_INTEGER_SIZE = 32 * and `x << 37` is equivalent to `x << 5`. */ private const val ECMASCRIPT_BITWISE_SHIFT_MASK = 0b11111 +private const val UNKNOWN_SIGNATURE_COMPONENT = "%unk" private enum class UpdateOperator { INCREMENT, @@ -340,46 +341,8 @@ class TsExprResolver( ) } - override fun visit(expr: EtsCastExpr): UExpr<*>? = with(ctx) { - val resolvedExpr = resolve(expr.arg) ?: return@with null - return when (resolvedExpr.sort) { - fp64Sort -> { - logger.error("Unsupported cast from fp ${expr.arg} to ${expr.type}") - TODO("Not yet implemented https://github.com/UnitTestBot/usvm/issues/299") - } - - boolSort -> { - logger.error("Unsupported cast from boolean ${expr.arg} to ${expr.type}") - TODO("Not yet implemented https://github.com/UnitTestBot/usvm/issues/299") - } - - addressSort -> { - scope.calcOnState { - val instance = resolvedExpr.asExpr(addressSort) - - if (instance.isFakeObject()) { - val fakeType = instance.getFakeType(scope) - pathConstraints += fakeType.refTypeExpr - val refValue = instance.extractRef(scope) - pathConstraints += memory.types.evalIsSubtype(refValue, expr.type) - return@calcOnState instance - } - - if (expr.type !is EtsRefType) { - logger.error("Unsupported cast from non-ref ${expr.arg} to ${expr.type}") - TODO("Not supported yet https://github.com/UnitTestBot/usvm/issues/299") - } - - pathConstraints += memory.types.evalIsSubtype(instance, expr.type) - instance - } - } - - else -> { - error("Unsupported cast from ${expr.arg} to ${expr.type}") - } - } - } + // TypeScript assertions are erased; they neither convert nor constrain the runtime value. + override fun visit(expr: EtsCastExpr): UExpr<*>? = resolve(expr.arg) override fun visit(expr: EtsTypeOfExpr): UExpr? = with(ctx) { val arg = resolve(expr.arg) ?: return null @@ -932,12 +895,19 @@ class TsExprResolver( val callee = scope.calcOnState { associatedFunction[ptr] } if (callee == null) { + val resolvedArguments = if (expr.isBuiltInArrayConstructor()) { + val argument = resolve(expr.args.single()) ?: return null + listOf(argument) + } else { + List(expr.args.size) { null } + } unknownCallDispatcher.dispatch( scope = scope, call = expr, callSite = scope.calcOnState { lastStmt }, failureReason = TsUnknownCallFailureReason.POINTER_TARGET_NOT_FOUND, resolvedReceiver = ptr, + resolvedArguments = resolvedArguments, ) return null } @@ -969,6 +939,26 @@ class TsExprResolver( } } + private fun EtsPtrCallExpr.isBuiltInArrayConstructor(): Boolean { + if (callee.name != "Array" || ptr.name != "Array" || args.size != 1) { + return false + } + + val signature = (ptr.type as? EtsFunctionType)?.signature ?: return false + val parameter = signature.parameters.singleOrNull() ?: return false + val returnType = signature.returnType as? EtsArrayType ?: return false + val signatureFile = signature.enclosingClass.file + + return signatureFile.projectName == UNKNOWN_SIGNATURE_COMPONENT && + signatureFile.fileName == UNKNOWN_SIGNATURE_COMPONENT && + signature.name.isEmpty() && + parameter.type == EtsNumberType && + parameter.isOptional && + !parameter.isRest && + returnType.elementType == EtsAnyType && + returnType.dimensions == 1 + } + private fun EtsPtrCallExpr.isBuiltInNumberConverter(): Boolean { val hasBuiltInCallSite = callee.name == "Number" && callee.enclosingClass == EtsClassSignature.UNKNOWN && diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt index 4e8059379..e315837ab 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteArray.kt @@ -78,6 +78,12 @@ internal fun TsExprResolver.handleAssignToArrayIndex( detail = "array index write would grow beyond the current length", ) }, + onUnsupportedElementKind = { + reportRuntimeFeatureLimitation( + reason = TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_KIND_WRITE, + detail = "array storage cannot represent the assigned runtime value kind", + ) + }, ) } @@ -88,6 +94,7 @@ fun TsContext.assignToArrayIndex( expr: UExpr<*>, arrayType: EtsArrayType, onUnsupportedGrowth: (() -> Unit)? = null, + onUnsupportedElementKind: (() -> Unit)? = null, ): Unit? { checkNotFake(array) @@ -114,6 +121,22 @@ fun TsContext.assignToArrayIndex( // If the element sort is known, write directly. if (elementSort !is TsUnresolvedSort) { + val (payload, kindGuard) = if (expr.isFakeObject()) { + val type = expr.getFakeType(scope) + when (elementSort) { + boolSort -> expr.extractBool(scope) to type.boolTypeExpr + fp64Sort -> expr.extractFp(scope) to type.fpTypeExpr + addressSort -> expr.extractRef(scope) to type.refTypeExpr + else -> error("Unsupported array element sort: $elementSort") + } + } else { + expr to mkBool(expr.sort == elementSort) + } + if (scope.checkSat(mkNot(kindGuard)) != null) { + onUnsupportedElementKind?.invoke() + } + scope.assert(kindGuard) ?: return null + val lValue = mkArrayIndexLValue( sort = elementSort, ref = array, @@ -121,7 +144,7 @@ fun TsContext.assignToArrayIndex( type = arrayType, ) return scope.doWithState { - memory.write(lValue, expr.asExpr(elementSort), guard = trueExpr) + memory.write(lValue, payload.asExpr(elementSort), guard = trueExpr) } } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 2b1731d0e..48e03120a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -150,7 +150,7 @@ fun TsContext.assignToInstanceField( val etsField = resolveEtsField(instanceLocal, field, hierarchy) // If we access some field, we expect that the object must have this field. // It is not always true for TS, but we decided to process it so. - if (!field.isDateModelTimestamp()) { + if (!field.isModelStorageField()) { val supertype = EtsAuxiliaryType(properties = setOf(field.name)) val propertyExists = scope.calcOnState { memory.types.evalIsSubtype(unwrappedInstance, supertype) } // The assertion is required to update models before the write. @@ -204,8 +204,13 @@ fun TsContext.assignToInstanceField( } } -private fun EtsFieldSignature.isDateModelTimestamp(): Boolean = - enclosingClass.name == "DateValue" && name == "timestamp" +private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { + "DateValue" -> name == "timestamp" + "ErrorValue" -> { + enclosingClass.file.fileName == "ErrorModels.ts" && (name == "name" || name == "message") + } + else -> false +} internal fun TsExprResolver.handleAssignToStaticField( lhv: EtsStaticFieldRef, diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts index 14754943f..db3b8b86c 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ArrayModels.ts @@ -1,9 +1,14 @@ declare class ArrayModelPrimitives { static grow(receiver: any[], length: number): void; + static allocate(length: number): any[]; static allocateLike(receiver: any[], length: number): any[]; } export class ArrayModels { + static fromLength(length: number): any[] { + return ArrayModelPrimitives.allocate(length); + } + static pop(receiver: any[]): any { const length = receiver.length; if (length === 0) { diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts new file mode 100644 index 000000000..b211cf4ab --- /dev/null +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts @@ -0,0 +1,12 @@ +export class ErrorValue { + name: string; + message: string; +} + +export class ErrorModels { + static construct(receiver: ErrorValue, message: string): ErrorValue { + receiver.name = "Error"; + receiver.message = message; + return receiver; + } +} diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts index a591116ff..a68e04b02 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/StringModels.ts @@ -107,6 +107,66 @@ export class StringModels { return StringModelPrimitives.copyRange(receiver, 0, end); } + static replaceAll(receiver: string, search: string, replacement: string): string { + const length = StringModelPrimitives.length(receiver); + const searchLength = StringModelPrimitives.length(search); + let result = ""; + let endOfLastMatch = 0; + let position = 0; + while (position + searchLength <= length) { + let offset = 0; + while (offset < searchLength && + StringModelPrimitives.codeUnitAt(receiver, position + offset) === + StringModelPrimitives.codeUnitAt(search, offset)) { + offset++; + } + if (offset === searchLength) { + result += StringModelPrimitives.copyRange(receiver, endOfLastMatch, position); + result += StringModels.substitution(receiver, search, replacement, position); + endOfLastMatch = position + searchLength; + position = endOfLastMatch + (searchLength === 0 ? 1 : 0); + } else { + position++; + } + } + return result + StringModelPrimitives.copyRange(receiver, endOfLastMatch, length); + } + private static substitution(receiver: string, search: string, replacement: string, position: number): string { + const replacementLength = StringModelPrimitives.length(replacement); + let result = ""; + let index = 0; + while (index < replacementLength) { + const code = StringModelPrimitives.codeUnitAt(replacement, index); + if (code === 36 && index + 1 < replacementLength) { + const next = StringModelPrimitives.codeUnitAt(replacement, index + 1); + if (next === 36) { + result += "$"; + index += 2; + continue; + } + if (next === 38) { + result += search; + index += 2; + continue; + } + if (next === 96) { + result += StringModelPrimitives.copyRange(receiver, 0, position); + index += 2; + continue; + } + if (next === 39) { + const matchEnd = position + StringModelPrimitives.length(search); + result += StringModelPrimitives.copyRange(receiver, matchEnd, StringModelPrimitives.length(receiver)); + index += 2; + continue; + } + } + result += StringModelPrimitives.fromCodeUnit(code); + index++; + } + return result; + } + private static isWhitespace(codeUnit: number): boolean { return (codeUnit >= 0x09 && codeUnit <= 0x0d) || codeUnit === 0x20 || codeUnit === 0xa0 || codeUnit === 0x1680 diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt new file mode 100644 index 000000000..893ab8b2e --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayConstructorEtsIrModelTest.kt @@ -0,0 +1,170 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.jacodb.ets.utils.loadEtsProjectAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsArrayConstructorEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ArrayConstructorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + private val importedShadowScene = loadEtsProjectAutoConvert( + getResourcePath("/models/array-constructor-shadow"), + useArkAnalyzerTypeInference = null, + ) + + @Test + fun `callable Array creates holes and accepts in bounds writes`() { + val result = analyze(methodName = "callableArrayCreatesHolesAndAcceptsWrites") + + assertEquals(13_456.0, result.singleNumber()) + assertEquals( + listOf("ts.array.fromLength", "ts.array.primitive.allocate"), + result.modelIds, + ) + } + + @Test + fun `callable Array result can be assigned to a nested array type`() { + val result = analyze(methodName = "callableArraySupportsNestedAssignment") + + assertEquals(256.0, result.singleNumber()) + assertEquals( + listOf("ts.array.fromLength", "ts.array.primitive.allocate"), + result.modelIds, + ) + } + + @Test + fun `invalid callable Array lengths remain residual`() { + for (methodName in listOf("negativeLength", "fractionalLength", "nanLength", "infiniteLength")) { + val result = analyze(methodName = methodName) + + assertTrue(result.values.isEmpty(), methodName) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome, methodName) + } + } + + @Test + fun `valid length above model capacity remains residual`() { + val result = analyze(methodName = "oversizedLength") + + assertTrue(result.values.isEmpty()) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, result.events.last().outcome) + } + + @Test + fun `full fill initializes every slot of a sparse array`() { + val expectedResults = mapOf( + "fullFillInitializesSparseNumberArray" to 3_777.0, + "fullFillInitializesSparseBooleanArray" to 3_111.0, + ) + + for ((methodName, expected) in expectedResults) { + val result = analyze(methodName = methodName) + + assertEquals(expected, result.singleNumber(), methodName) + assertEquals(listOf("ts.array.fill", "ts.math.floor"), result.modelIds, methodName) + } + } + + @Test + fun `shadowed Array function is not modeled as the global constructor`() { + val result = analyze(methodName = "shadowedArrayIsNotModeled") + + assertEquals(2.0, result.singleNumber()) + assertTrue(result.events.isEmpty()) + } + + @Test + fun `imported Array function is not modeled as the global constructor`() { + val result = analyze( + methodName = "callImportedArray", + className = "ImportedArrayShadow", + scene = importedShadowScene, + ) + + assertEquals(99.0, result.singleNumber()) + assertTrue(result.events.isEmpty()) + } + + private fun analyze( + methodName: String, + className: String = "ArrayConstructorEtsIr", + scene: EtsScene = this.scene, + ): AnalysisResult { + val method = method(scene, className, methodName) + val observer = RecordingUnknownCallObserver() + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> + val states = machine.analyze(listOf(method)) + AnalysisResult( + values = states.map { state -> TsTestResolver().resolve(method, state).returnValue }, + events = observer.events.toList(), + ) + } + } + + private fun method(scene: EtsScene, className: String, methodName: String): EtsMethod = scene.projectClasses + .single { it.name == className } + .methods + .single { it.name == methodName } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val values: List, + val events: List, + ) { + fun singleNumber(): Double = assertIs(values.single()).number + + val modelIds: List + get() = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt index c2dd1c7aa..1ae4fb4a3 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsArrayShiftReplayTest.kt @@ -153,11 +153,67 @@ class TsArrayShiftReplayTest { """.trimIndent(), ) ) + addAll(assertionAndWriteCases()) addAll(storageOperationCases()) addAll(pairCases()) addAll(typedCases()) } + private fun assertionAndWriteCases(): List = buildList { + add( + ReplayCase( + name = "number type assertions preserve runtime values", + parameters = "value: number", + maxResult = 1, + body = """ + if (value !== 42) return 0; + const asserted = value as unknown as string; + return typeof asserted === 'number' && asserted === 42 ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "boolean type assertions do not convert to numbers", + parameters = "value: boolean", + maxResult = 1, + body = """ + if (!value) return 0; + const asserted = value as unknown as number; + return typeof asserted === 'boolean' && asserted === true ? 1 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "truthy wrapped number retains its runtime kind through assertion and write", + parameters = "values: number[]", + maxResult = 2, + body = """ + if (values.length !== 1) return 0; + const item = values[0]; + if (!item) return 1; + const asserted = item as unknown as number; + const output = [0]; + output[0] = asserted; + return output[0] === item ? 2 : -1; + """.trimIndent(), + ) + ) + add( + ReplayCase( + name = "typed array copies preserve wrapped numbers including NaN", + parameters = "values: number[]", + maxResult = 1, + body = """ + if (values.length !== 2) return 0; + values[0] = values[1]; + return values[0] === values[1] || values[0] !== values[0] ? 1 : -1; + """.trimIndent(), + ) + ) + } + private fun storageOperationCases(): List = listOf("any", "unknown").flatMap { type -> copyCases(type) + mutationCases(type) + concatCases(type) }.map { case -> case.copy(name = "compatibility storage: ${case.name}", compatibility = true) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt new file mode 100644 index 000000000..047cd33da --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt @@ -0,0 +1,135 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsMethod +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.machine.call.intrinsic.TsErrorEtsIrModelFamily +import org.usvm.machine.state.TsMethodResult +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsErrorEtsIrModelTest { + private val builtInSourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/ErrorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val shadowSourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/UserDefinedErrorEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val builtInScene = EtsScene(projectFiles = listOf(builtInSourceFile)) + private val shadowScene = EtsScene(projectFiles = listOf(shadowSourceFile)) + + @Test + fun `Error constructor initializes name and exact message through source model`() { + val expected = mapOf( + "name" to "Error", + "message" to "expected message", + ) + + expected.forEach { (methodName, expectedValue) -> + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = methodName) + val actual = assertIs(result.values.single()).value + + assertEquals(expectedValue, actual, methodName) + assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) + } + } + + @Test + fun `modeled Error receiver remains the thrown exception`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "throwError") + + assertEquals(1, result.states.size) + assertIs(result.states.single().methodResult) + assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) + assertTrue(result.values.single() is TsTestValue.TsException) + } + + @Test + fun `Error typed field read does not reinterpret a user object as model storage`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "castUserObjectName") + val actual = assertIs(result.values.single()).number + + assertEquals(17.0, actual) + assertTrue(result.modelIds.isEmpty()) + } + + @Test + fun `user defined Error constructor does not use builtin model`() { + val result = analyze(scene = shadowScene, className = "UserDefinedErrorEtsIr", methodName = "name") + val actual = assertIs(result.values.single()).value + + assertEquals("ShadowError", actual) + assertTrue(result.modelIds.isEmpty()) + } + + private fun analyze(scene: EtsScene, className: String, methodName: String): AnalysisResult { + val method = method(scene = scene, className = className, methodName = methodName) + val observer = RecordingUnknownCallObserver() + val states = TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(), + observer = observer, + ).use { machine -> machine.analyze(listOf(method)) } + val values = states.map { state -> TsTestResolver().resolve(method, state).returnValue } + + return AnalysisResult( + states = states, + values = values, + events = observer.events.toList(), + ) + } + + private fun method(scene: EtsScene, className: String, methodName: String): EtsMethod = scene.projectClasses + .single { clazz -> clazz.name == className } + .methods + .single { method -> method.name == methodName } + + private class RecordingUnknownCallObserver : TsInterpreterObserver { + val events = mutableListOf() + + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + private data class AnalysisResult( + val states: List, + val values: List, + val events: List, + ) { + val modelIds: List = events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ModelApplied)?.modelId + } + } + + private companion object { + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 3_500L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt new file mode 100644 index 000000000..b47a28f34 --- /dev/null +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt @@ -0,0 +1,96 @@ +package org.usvm.machine.call + +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.usvm.PathSelectionStrategy +import org.usvm.SolverType +import org.usvm.StateCollectionStrategy +import org.usvm.UMachineOptions +import org.usvm.api.TsTestValue +import org.usvm.machine.TsInterpreterObserver +import org.usvm.machine.TsMachine +import org.usvm.machine.TsOptions +import org.usvm.util.TsTestResolver +import org.usvm.util.getResourcePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +class TsStringReplaceAllEtsIrModelTest { + private val sourceFile = loadEtsFileAutoConvert( + getResourcePath("/models/StringReplaceAllEtsIr.ts"), + provider = EtsIrProvider.TS_FRONTEND, + ) + private val scene = EtsScene(listOf(sourceFile)) + + @Test + fun `literal string replacement implements substitution and UTF16 boundaries`() { + val cases = mapOf( + "literalMatches" to "XX/aba", + "emptySearch" to "-A-\uD83D-\uDE00-", + "substitutions" to "\$:a::ba:\$1b\$:a:ab::\$1", + "noMatches" to "abc", + "emptyReceiver" to "\$", + "replacementIsNotSearchedAgain" to "aaaa", + ) + + cases.forEach { (methodName, expected) -> + val (values, events) = analyze(methodName) + + assertEquals(expected, assertIs(values.single()).value, methodName) + assertTrue(events.any { (it.decision as? TsUnknownCallDecision.ModelApplied)?.modelId == MODEL_ID }) + assertTrue(events.all { it.outcome == TsUnknownCallOutcome.MODEL_APPLIED }) + } + } + + @Test + fun `non string arguments remain explicit residual calls`() { + for (methodName in listOf("numericSearchIsResidual", "numericReplacementIsResidual")) { + val (values, events) = analyze(methodName) + + assertTrue(values.isEmpty(), methodName) + assertEquals(TsUnknownCallOutcome.PATH_STOPPED, events.single().outcome, methodName) + } + } + + private fun analyze(methodName: String): Pair, List> { + val method = scene.projectClasses.single { it.name == "StringReplaceAllEtsIr" }.methods + .single { it.name == methodName } + val events = mutableListOf() + val observer = object : TsInterpreterObserver { + override fun onUnknownCall(event: TsUnknownCallEvent) { + events += event + } + } + + return TsMachine( + scene = scene, + options = machineOptions, + tsOptions = TsOptions(unknownCallModelSelection = TsUnknownCallModelSelection.Only(setOf(MODEL_ID))), + observer = observer, + ).use { machine -> + val values = machine.analyze(listOf(method)).map { state -> + TsTestResolver().resolve(method, state).returnValue + } + values to events.toList() + } + } + + private companion object { + const val MODEL_ID = "ts.string.replaceAll" + val machineOptions = UMachineOptions( + pathSelectionStrategies = listOf(PathSelectionStrategy.BFS), + stateCollectionStrategy = StateCollectionStrategy.ALL, + exceptionsPropagation = true, + throwExceptionOnStepFailure = true, + timeout = Duration.INFINITE, + stepsFromLastCovered = 20_000L, + solverType = SolverType.YICES, + solverTimeout = Duration.INFINITE, + typeOperationsTimeout = Duration.INFINITE, + ) + } +} diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt index bdf8c6057..82a7fe1b8 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsUnknownCallModelCatalogTest.kt @@ -12,6 +12,7 @@ import org.usvm.UMachineOptions import org.usvm.machine.TsMachine import org.usvm.machine.TsOptions import org.usvm.machine.call.intrinsic.TsArrayShiftIntrinsicModel +import org.usvm.machine.call.intrinsic.TsErrorEtsIrModelFamily import org.usvm.machine.call.intrinsic.TsNumericIntrinsicModelFamily import org.usvm.machine.state.TsState import kotlin.test.Test @@ -209,6 +210,7 @@ class TsUnknownCallModelCatalogTest { assertTrue(catalog.modelIds.containsAll(expectedModelIds)) assertTrue("ts.date.constructor" in catalog.modelIds) + assertTrue(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID in catalog.modelIds) assertTrue("ts.date.now" in catalog.modelIds) assertEquals(expected = 38, actual = catalog.modelIds.count { it.startsWith("ts.date.") }) assertEquals(catalog.modelIds.distinct().sorted(), catalog.modelIds) diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt index 848683b70..64bf39159 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/expr/NumericBoundarySemanticsTest.kt @@ -66,6 +66,7 @@ class NumericBoundarySemanticsTest { "negativeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, "outOfRangeArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_INDEX_GROWTH, "chainedMissingArrayWrite" to TsRuntimeFeatureLimitationReason.ARRAY_NAMED_PROPERTY_WRITE, + "typedArrayCannotStoreMissingValue" to TsRuntimeFeatureLimitationReason.ARRAY_ELEMENT_KIND_WRITE, ) unsupportedMethods.forEach { (methodName, reason) -> diff --git a/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts b/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts new file mode 100644 index 000000000..8fe9120e3 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ArrayConstructorEtsIr.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +export class ArrayConstructorEtsIr { + callableArrayCreatesHolesAndAcceptsWrites(): number { + const values = Array(3); + const startsWithUndefined = values[0] === undefined; + values[0] = 4; + values[1] = 5; + values[2] = 6; + + return (startsWithUndefined ? 1 : 0) * 10000 + + values.length * 1000 + + values[0] * 100 + + values[1] * 10 + + values[2]; + } + + callableArraySupportsNestedAssignment(): number { + const result: number[][] = Array(2); + result[0] = [4, 5]; + result[1] = [6]; + + return result.length * 100 + result[0][1] * 10 + result[1][0]; + } + + negativeLength(): any[] { + return Array(-1); + } + + fractionalLength(): any[] { + return Array(1.5); + } + + nanLength(): any[] { + return Array(NaN); + } + + infiniteLength(): any[] { + return Array(Infinity); + } + + oversizedLength(): any[] { + return Array(17); + } + + fullFillInitializesSparseNumberArray(): number { + const values = new Array(3).fill(7); + return values.length * 1000 + values[0] * 100 + values[1] * 10 + values[2]; + } + + fullFillInitializesSparseBooleanArray(): number { + const values = new Array(3).fill(true); + return values.length * 1000 + + (values[0] ? 1 : 0) * 100 + + (values[1] ? 1 : 0) * 10 + + (values[2] ? 1 : 0); + } + + shadowedArrayIsNotModeled(): number { + const Array = (arrayLength?: number): any[] => [arrayLength]; + return Array(2)[0]; + } +} diff --git a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts new file mode 100644 index 000000000..02840dbc9 --- /dev/null +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -0,0 +1,22 @@ +class OtherWithNumericName { + name: number = 17; +} + +export class ErrorEtsIr { + static name(): string { + return new Error("expected message").name; + } + + static message(): string { + return new Error("expected message").message; + } + + static throwError(): number { + throw new Error("expected message"); + } + + static castUserObjectName(): number { + const value = new OtherWithNumericName(); + return (value as unknown as Error).name as unknown as number; + } +} diff --git a/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts index b25a26c40..433c8737d 100644 --- a/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts +++ b/usvm-ts/src/test/resources/models/NumericBoundarySemantics.ts @@ -2,6 +2,13 @@ // noinspection JSUnusedGlobalSymbols export class NumericBoundarySemantics { + typedArrayCannotStoreMissingValue(): number { + const source = [11]; + const destination = [22]; + destination[0] = source[2]; + return destination[0]; + } + bitwiseNaN(): number { return (0 / 0) >> 1; } diff --git a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts new file mode 100644 index 000000000..8617009d4 --- /dev/null +++ b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts @@ -0,0 +1,34 @@ +// @ts-nocheck +export class StringReplaceAllEtsIr { + literalMatches(): string { + return "aaaa/aba".replaceAll("aa", "X"); + } + + emptySearch(): string { + return "A😀".replaceAll("", "-"); + } + + substitutions(): string { + return "aba".replaceAll("a", "$$:$&:$`:$':$1"); + } + + noMatches(): string { + return "abc".replaceAll("x", "$&"); + } + + emptyReceiver(): string { + return "".replaceAll("", "$$"); + } + + replacementIsNotSearchedAgain(): string { + return "aa".replaceAll("a", "aa"); + } + + numericSearchIsResidual(): string { + return "123".replaceAll(1, "x"); + } + + numericReplacementIsResidual(): string { + return "abc".replaceAll("a", 1); + } +} diff --git a/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts b/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts new file mode 100644 index 000000000..6745cccda --- /dev/null +++ b/usvm-ts/src/test/resources/models/UserDefinedErrorEtsIr.ts @@ -0,0 +1,14 @@ +class Error { + name: string = "ShadowError"; + message: string; + + constructor(message: string) { + this.message = message; + } +} + +export class UserDefinedErrorEtsIr { + static name(): string { + return new Error("shadow message").name; + } +} diff --git a/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts b/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts new file mode 100644 index 000000000..8c3ccc469 --- /dev/null +++ b/usvm-ts/src/test/resources/models/array-constructor-shadow/exported.ts @@ -0,0 +1,3 @@ +// @ts-nocheck + +export const Array = (length?: number): any[] => [99]; diff --git a/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts b/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts new file mode 100644 index 000000000..524567379 --- /dev/null +++ b/usvm-ts/src/test/resources/models/array-constructor-shadow/main.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols + +import { Array } from "./exported"; + +export class ImportedArrayShadow { + callImportedArray(): number { + return Array(2)[0]; + } +} From 2502bdb873d40954d56723fdf3b92b17592cd3ef Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 23:00:06 +0300 Subject: [PATCH 32/33] Reject callable arguments before querying modeled string types --- .../call/intrinsic/TsErrorEtsIrModelFamily.kt | 2 +- .../call/intrinsic/TsStringEtsIrModelFamily.kt | 14 ++++++++++---- .../usvm/machine/call/TsErrorEtsIrModelTest.kt | 15 ++++++++++----- .../call/TsStringReplaceAllEtsIrModelTest.kt | 9 ++++++++- usvm-ts/src/test/resources/models/ErrorEtsIr.ts | 9 ++------- .../resources/models/StringReplaceAllEtsIr.ts | 8 ++++++++ 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt index 6da50ef18..ff549a595 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsErrorEtsIrModelFamily.kt @@ -63,7 +63,7 @@ internal object TsErrorEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { if (receiver.hasFakeValueBranch() || receiverRuntimeType != builtinErrorType) { return@TsEtsIrUnknownCallModelDomainGuard falseExpr } - if (message.hasFakeValueBranch()) { + if (message.hasFakeValueBranch() || state.associatedFunction.containsKey(message)) { return@TsEtsIrUnknownCallModelDomainGuard falseExpr } diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt index 33d21423a..c72b65eb9 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/intrinsic/TsStringEtsIrModelFamily.kt @@ -142,7 +142,9 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val receiverDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> with(state.ctx) { val receiver = inputs.firstOrNull() - if (receiver !is UConcreteHeapRef || receiver.hasFakeValueBranch()) { + if (receiver !is UConcreteHeapRef || receiver.hasFakeValueBranch() || + state.associatedFunction.containsKey(receiver) + ) { falseExpr } else { state.memory.types.evalTypeEquals(receiver, EtsStringType) @@ -154,8 +156,10 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { with(state.ctx) { val receiver = inputs.getOrNull(0) val searchString = inputs.getOrNull(1) - val receiverIsConstant = receiver is UConcreteHeapRef && !receiver.hasFakeValueBranch() - val searchStringIsConstant = searchString is UConcreteHeapRef && !searchString.hasFakeValueBranch() + val receiverIsConstant = receiver is UConcreteHeapRef && !receiver.hasFakeValueBranch() && + !state.associatedFunction.containsKey(receiver) + val searchStringIsConstant = searchString is UConcreteHeapRef && !searchString.hasFakeValueBranch() && + !state.associatedFunction.containsKey(searchString) if (!receiverIsConstant || !searchStringIsConstant) { falseExpr @@ -175,7 +179,9 @@ internal object TsStringEtsIrModelFamily : TsBuiltInUnknownCallModelFamily { private val replaceAllDomain = TsEtsIrUnknownCallModelDomainGuard { state, _, inputs -> with(state.ctx) { val strings = inputs.filterIsInstance() - if (strings.size != REPLACE_ALL_INPUT_COUNT || strings.any { it.hasFakeValueBranch() }) { + if (strings.size != REPLACE_ALL_INPUT_COUNT || + strings.any { it.hasFakeValueBranch() || state.associatedFunction.containsKey(it) } + ) { falseExpr } else { val guards = strings.map { state.memory.types.evalTypeEquals(it, EtsStringType) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt index 047cd33da..86628c5ec 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt @@ -61,11 +61,16 @@ class TsErrorEtsIrModelTest { } @Test - fun `Error typed field read does not reinterpret a user object as model storage`() { - val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "castUserObjectName") - val actual = assertIs(result.values.single()).number - - assertEquals(17.0, actual) + fun `callback Error message remains residual`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "callbackMessage") + + assertTrue(result.states.isEmpty()) + assertEquals( + listOf(TsResidualCallPolicy.STOP_PATH), + result.events.mapNotNull { event -> + (event.decision as? TsUnknownCallDecision.ResidualFallback)?.policy + }, + ) assertTrue(result.modelIds.isEmpty()) } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt index b47a28f34..f0f6df765 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsStringReplaceAllEtsIrModelTest.kt @@ -48,7 +48,14 @@ class TsStringReplaceAllEtsIrModelTest { @Test fun `non string arguments remain explicit residual calls`() { - for (methodName in listOf("numericSearchIsResidual", "numericReplacementIsResidual")) { + val cases = listOf( + "numericSearchIsResidual", + "numericReplacementIsResidual", + "callbackReplacementIsResidual", + "callableSearchIsResidual", + ) + + for (methodName in cases) { val (values, events) = analyze(methodName) assertTrue(values.isEmpty(), methodName) diff --git a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts index 02840dbc9..c6ddb3ab6 100644 --- a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -1,7 +1,3 @@ -class OtherWithNumericName { - name: number = 17; -} - export class ErrorEtsIr { static name(): string { return new Error("expected message").name; @@ -15,8 +11,7 @@ export class ErrorEtsIr { throw new Error("expected message"); } - static castUserObjectName(): number { - const value = new OtherWithNumericName(); - return (value as unknown as Error).name as unknown as number; + static callbackMessage(): string { + return new Error((() => "x") as any).message; } } diff --git a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts index 8617009d4..685f56c3a 100644 --- a/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts +++ b/usvm-ts/src/test/resources/models/StringReplaceAllEtsIr.ts @@ -31,4 +31,12 @@ export class StringReplaceAllEtsIr { numericReplacementIsResidual(): string { return "abc".replaceAll("a", 1); } + + callbackReplacementIsResidual(): string { + return "aa".replaceAll("a", (match: string) => match); + } + + callableSearchIsResidual(): string { + return "aa".replaceAll((match: string) => match, "x"); + } } From 1ec9cd9b4f39615ab4827ddd538167a6304a020f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 20 Sep 2026 23:07:38 +0300 Subject: [PATCH 33/33] Isolate Error model storage from user property resolution --- .../usvm/machine/expr/ErrorModelStorage.kt | 29 ++++++++++++++ .../kotlin/org/usvm/machine/expr/ReadField.kt | 22 +++++------ .../org/usvm/machine/expr/WriteField.kt | 38 +++++++++++++++---- .../usvm/machine/call/models/ErrorModels.ts | 9 +++-- .../machine/call/TsErrorEtsIrModelTest.kt | 14 +++++++ .../src/test/resources/models/ErrorEtsIr.ts | 32 ++++++++++++++++ 6 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt new file mode 100644 index 000000000..1a97c0773 --- /dev/null +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ErrorModelStorage.kt @@ -0,0 +1,29 @@ +package org.usvm.machine.expr + +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsFieldSignature + +internal val builtInErrorSignature = EtsClassSignature.UNKNOWN.copy(name = "Error") + +internal const val ERROR_NAME_STORAGE_FIELD = "__usvmErrorName" +internal const val ERROR_MESSAGE_STORAGE_FIELD = "__usvmErrorMessage" + +internal fun EtsFieldSignature.isErrorModelStorageDefinitionField(): Boolean = + enclosingClass.name == "ErrorValue" && + enclosingClass.file.fileName == "ErrorModels.ts" && + (name == ERROR_NAME_STORAGE_FIELD || name == ERROR_MESSAGE_STORAGE_FIELD) + +internal fun EtsFieldSignature.isUnresolvedErrorField(): Boolean = + enclosingClass == builtInErrorSignature && (name == "name" || name == "message") + +internal fun EtsFieldSignature.errorModelStorageField(): String? { + if (enclosingClass != builtInErrorSignature && enclosingClass != EtsClassSignature.UNKNOWN) { + return null + } + + return when (name) { + "name" -> ERROR_NAME_STORAGE_FIELD + "message" -> ERROR_MESSAGE_STORAGE_FIELD + else -> null + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt index 8f35be8d6..d6990b9fa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/ReadField.kt @@ -71,11 +71,16 @@ fun TsContext.readField( checkNotFake(instance) val isUnresolvedErrorField = field.isUnresolvedErrorField() - val isErrorModelStorageField = isUnresolvedErrorField && instance is UConcreteHeapRef && scope.calcOnState { - memory.typeStreamOf(instance).singleOrNull() == EtsClassType( - signature = EtsClassSignature.UNKNOWN.copy(name = "Error"), - ) + val candidateErrorStorageField = field.errorModelStorageField() + val concreteRuntimeType = (instance as? UConcreteHeapRef) + ?.takeIf { candidateErrorStorageField != null } + ?.let { concreteInstance -> + scope.calcOnState { memory.typeStreamOf(concreteInstance).singleOrNull() } + } + val errorStorageField = candidateErrorStorageField.takeIf { + concreteRuntimeType == EtsClassType(signature = builtInErrorSignature) } + val isErrorModelStorageField = errorStorageField != null val etsField = when { isErrorModelStorageField -> TsResolutionResult.Empty isUnresolvedErrorField -> resolveEtsField( @@ -120,7 +125,7 @@ fun TsContext.readField( // If the field type is known, we can read it directly. if (sort !is TsUnresolvedSort) { - val lValue = mkFieldLValue(sort, instance, field) + val lValue = mkFieldLValue(sort, instance, errorStorageField ?: field.name) return scope.calcOnState { memory.read(lValue) } } @@ -149,15 +154,10 @@ fun TsContext.readField( private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { "DateValue" -> name == "timestamp" - "ErrorValue" -> { - enclosingClass.file.fileName == "ErrorModels.ts" && (name == "name" || name == "message") - } + "ErrorValue" -> isErrorModelStorageDefinitionField() else -> false } -private fun EtsFieldSignature.isUnresolvedErrorField(): Boolean = - enclosingClass == EtsClassSignature.UNKNOWN.copy(name = "Error") && (name == "name" || name == "message") - internal fun TsExprResolver.handleStaticFieldRef( value: EtsStaticFieldRef, ): UExpr<*>? = with(ctx) { diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt index 48e03120a..fbbb2943a 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/expr/WriteField.kt @@ -4,13 +4,17 @@ import io.ksmt.utils.asExpr import mu.KotlinLogging import org.jacodb.ets.model.EtsArrayType import org.jacodb.ets.model.EtsBooleanType +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsFieldSignature import org.jacodb.ets.model.EtsInstanceFieldRef import org.jacodb.ets.model.EtsLocal import org.jacodb.ets.model.EtsNumberType import org.jacodb.ets.model.EtsStaticFieldRef +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UHeapRef +import org.usvm.api.typeStreamOf import org.usvm.machine.TsContext import org.usvm.machine.TsRuntimeFeatureLimitationReason import org.usvm.machine.interpreter.TsStepScope @@ -18,6 +22,7 @@ import org.usvm.machine.interpreter.ensureStaticsInitialized import org.usvm.machine.types.EtsAuxiliaryType import org.usvm.machine.types.extractValue import org.usvm.sizeSort +import org.usvm.types.singleOrNull import org.usvm.util.EtsHierarchy import org.usvm.util.TsResolutionResult import org.usvm.util.arrayStorageType @@ -147,10 +152,29 @@ fun TsContext.assignToInstanceField( // Unwrap to get non-fake reference. val unwrappedInstance = instance.unwrapRef(scope) - val etsField = resolveEtsField(instanceLocal, field, hierarchy) + val isUnresolvedErrorField = field.isUnresolvedErrorField() + val candidateErrorStorageField = field.errorModelStorageField() + val concreteRuntimeType = (unwrappedInstance as? UConcreteHeapRef) + ?.takeIf { candidateErrorStorageField != null } + ?.let { concreteInstance -> + scope.calcOnState { memory.typeStreamOf(concreteInstance).singleOrNull() } + } + val errorStorageField = candidateErrorStorageField.takeIf { + concreteRuntimeType == EtsClassType(signature = builtInErrorSignature) + } + val isModelStorageField = field.isModelStorageField() || errorStorageField != null + val etsField = when { + errorStorageField != null -> TsResolutionResult.Empty + isUnresolvedErrorField -> resolveEtsField( + instance = instanceLocal, + field = field.copy(enclosingClass = EtsClassSignature.UNKNOWN), + hierarchy = hierarchy, + ) + else -> resolveEtsField(instanceLocal, field, hierarchy) + } // If we access some field, we expect that the object must have this field. // It is not always true for TS, but we decided to process it so. - if (!field.isModelStorageField()) { + if (!isModelStorageField) { val supertype = EtsAuxiliaryType(properties = setOf(field.name)) val propertyExists = scope.calcOnState { memory.types.evalIsSubtype(unwrappedInstance, supertype) } // The assertion is required to update models before the write. @@ -159,7 +183,7 @@ fun TsContext.assignToInstanceField( // Determine the field sort. val sort = when (etsField) { - is TsResolutionResult.Empty -> unresolvedSort + is TsResolutionResult.Empty -> if (errorStorageField != null) addressSort else unresolvedSort is TsResolutionResult.Unique -> typeToSort(etsField.property.type) is TsResolutionResult.Ambiguous -> unresolvedSort } @@ -169,11 +193,11 @@ fun TsContext.assignToInstanceField( return scope.doWithState { if (sort is TsUnresolvedSort) { val fakeObject = expr.toFakeObject(scope) - val lValue = mkFieldLValue(addressSort, unwrappedInstance, field) + val lValue = mkFieldLValue(addressSort, unwrappedInstance, field.name) lValuesToAllocatedFakeObjects += lValue to fakeObject memory.write(lValue, fakeObject, guard = trueExpr) } else { - val lValue = mkFieldLValue(sort, unwrappedInstance, field) + val lValue = mkFieldLValue(sort, unwrappedInstance, errorStorageField ?: field.name) if (lValue.sort != expr.sort) { if (expr.isFakeObject()) { val lhvType = instanceLocal.type @@ -206,9 +230,7 @@ fun TsContext.assignToInstanceField( private fun EtsFieldSignature.isModelStorageField(): Boolean = when (enclosingClass.name) { "DateValue" -> name == "timestamp" - "ErrorValue" -> { - enclosingClass.file.fileName == "ErrorModels.ts" && (name == "name" || name == "message") - } + "ErrorValue" -> isErrorModelStorageDefinitionField() else -> false } diff --git a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts index b211cf4ab..ca847a6ea 100644 --- a/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts +++ b/usvm-ts/src/main/resources/org/usvm/machine/call/models/ErrorModels.ts @@ -1,12 +1,13 @@ export class ErrorValue { - name: string; - message: string; + // Keep model storage out of unresolved user-property lookup. + __usvmErrorName: string; + __usvmErrorMessage: string; } export class ErrorModels { static construct(receiver: ErrorValue, message: string): ErrorValue { - receiver.name = "Error"; - receiver.message = message; + receiver.__usvmErrorName = "Error"; + receiver.__usvmErrorMessage = message; return receiver; } } diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt index 86628c5ec..70a33fa07 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt @@ -39,6 +39,9 @@ class TsErrorEtsIrModelTest { val expected = mapOf( "name" to "Error", "message" to "expected message", + "overwrittenName" to "CustomError", + "overwrittenMessage" to "after", + "anyErrorMessage" to "aliased message", ) expected.forEach { (methodName, expectedValue) -> @@ -60,6 +63,17 @@ class TsErrorEtsIrModelTest { assertTrue(result.values.single() is TsTestValue.TsException) } + @Test + fun `Error model storage does not affect ordinary any field resolution`() { + listOf("anyForeignNameComparison", "anyForeignMessageComparison").forEach { methodName -> + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = methodName) + val actual = result.values.map { value -> assertIs(value).number } + + assertEquals(listOf(1.0), actual, methodName) + assertTrue(result.modelIds.isEmpty(), methodName) + } + } + @Test fun `callback Error message remains residual`() { val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "callbackMessage") diff --git a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts index c6ddb3ab6..74c2ee6d8 100644 --- a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -1,3 +1,8 @@ +class ForeignFields { + name: number = 17; + message: boolean = true; +} + export class ErrorEtsIr { static name(): string { return new Error("expected message").name; @@ -11,6 +16,33 @@ export class ErrorEtsIr { throw new Error("expected message"); } + static overwrittenName(): string { + const error = new Error("expected message"); + error.name = "CustomError"; + return error.name; + } + + static overwrittenMessage(): string { + const error = new Error("before"); + error.message = "after"; + return error.message; + } + + static anyErrorMessage(): string { + const error: any = new Error("aliased message"); + return error.message; + } + + static anyForeignNameComparison(): number { + const value: any = new ForeignFields(); + return value.name === 17 ? 1 : -1; + } + + static anyForeignMessageComparison(): number { + const value: any = new ForeignFields(); + return value.message === true ? 1 : -1; + } + static callbackMessage(): string { return new Error((() => "x") as any).message; }