Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b78115d
Adopt JacoDB native TypeScript frontend
CaelmBleidd Aug 21, 2026
27ad246
[TS] Map literal types to base sorts
CaelmBleidd Aug 21, 2026
6132231
[TS] Advance execution through NOP statements
CaelmBleidd Aug 21, 2026
35f1f9c
[TS] Implement increment and decrement expressions
CaelmBleidd Aug 21, 2026
05cecb4
[TS] Canonicalize array memory descriptors
CaelmBleidd Aug 21, 2026
e9ce7bf
[TS] Bind lexical environments to closures
CaelmBleidd Aug 21, 2026
3d62679
[TS] Resolve Number pointer calls
CaelmBleidd Aug 21, 2026
31062ae
[TS] Initialize native module fields
CaelmBleidd Aug 21, 2026
cb56e5b
[TS] Concatenate concrete string operands
CaelmBleidd Aug 21, 2026
1cf4476
[TS] Preserve nominal instanceof alternatives
CaelmBleidd Aug 21, 2026
1d49d64
[TS] Check type guesser fixed point
CaelmBleidd Aug 21, 2026
593b2bb
[TS] Execute overload implementations
CaelmBleidd Aug 21, 2026
d677b3d
[TS] Support Number without arguments
CaelmBleidd Aug 22, 2026
b5e0d09
[TS] Canonicalize static overload groups before forking
CaelmBleidd Aug 22, 2026
6d1436a
[TS] Format concrete numbers in string concatenation
CaelmBleidd Aug 22, 2026
3454b8b
[TS] Clean up native frontend fix style
CaelmBleidd Aug 22, 2026
92d7c8f
[CI] Pin ArkAnalyzer Node types
CaelmBleidd Aug 22, 2026
c0f9a5b
[TS] Pin published native frontend artifact
CaelmBleidd Aug 22, 2026
62c1d94
[TS] Pin the hardened frontend artifact
CaelmBleidd Aug 22, 2026
55ee546
[TS] Pin the merged native frontend artifact
CaelmBleidd Aug 22, 2026
912de52
[TS] Address native frontend review feedback
CaelmBleidd Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ jobs:
echo "ARKANALYZER_DIR=$(realpath $DEST_DIR)" >> $GITHUB_ENV
cd $DEST_DIR

# ArkAnalyzer's bundled TypeScript 4.9 cannot parse syntax from the
# latest floating @types/node, so pin a compatible major before the
# single install. A second install would prune the postinstall-only
# ohos-typescript package because it is absent from package.json.
npm pkg set 'devDependencies.@types/node=18'
npm install
npm run build

Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ object Versions {
const val clikt = "5.0.0"
const val detekt = "1.23.7"
const val ini4j = "0.5.4"
const val jacodb = "b17013382a"
const val jacodb = "9ea33879c9"
const val juliet = "1.3.2"
const val junit = "5.9.3"
const val kotlin = "2.1.0"
Expand Down
35 changes: 20 additions & 15 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,23 @@ findProject(":usvm-python:usvm-python-commons")?.name = "usvm-python-commons"
// Actually, relative path is enough, but there is a bug in IDEA when the path is a symlink.
// As a workaround, we convert it to a real absolute path.
// See IDEA bug: https://youtrack.jetbrains.com/issue/IDEA-329756
// val jacodbPath = file("jacodb").takeIf { it.exists() }
// ?: file("../jacodb").takeIf { it.exists() }
// ?: error("Local JacoDB directory not found")
// includeBuild(jacodbPath.toPath().toRealPath().toAbsolutePath()) {
// dependencySubstitution {
// all {
// val requested = requested
// if (requested is ModuleComponentSelector && requested.group == "com.github.UnitTestBot.jacodb") {
// val targetProject = ":${requested.module}"
// useTarget(project(targetProject))
// logger.info("Substituting ${requested.group}:${requested.module} with $targetProject")
// }
// }
// }
// }
// Opt-in local JacoDB substitution: -PuseLocalJacodb[=<path>] (default: ./jacodb or ../jacodb)
if (extra.has("useLocalJacodb")) {
val prop = extra.get("useLocalJacodb")?.toString()
val jacodbPath = prop?.takeIf { it.isNotBlank() && it != "true" }?.let { file(it) }
?: file("jacodb").takeIf { it.exists() }
?: file("../jacodb").takeIf { it.exists() }
?: error("Local JacoDB directory not found")
includeBuild(jacodbPath.toPath().toRealPath().toAbsolutePath()) {
dependencySubstitution {
all {
val requested = requested
if (requested is ModuleComponentSelector && requested.group == "com.github.UnitTestBot.jacodb") {
val targetProject = ":${requested.module}"
useTarget(project(targetProject))
logger.info("Substituting ${requested.group}:${requested.module} with $targetProject")
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import kotlin.io.path.toPath
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

private val logger = KotlinLogging.logger {}
Expand Down Expand Up @@ -304,7 +303,7 @@ class EtsTypeInferenceTest {
}

@Test
fun `test if guesser does anything`() {
fun `type guesser reaches a fixed point after one pass`() {
val name = "testcases"
val file = load("/ts/$name.ts")
val project = EtsScene(listOf(file))
Expand All @@ -322,8 +321,9 @@ class EtsTypeInferenceTest {
val manager = TypeInferenceManager(EtsTraits(), graph)
val resultWithoutGuessed = manager.analyze(entrypoints)
val resultWithGuessed = resultWithoutGuessed.withGuessedTypes(guesser)
val resultWithGuessedTwice = resultWithGuessed.withGuessedTypes(guesser)

assertNotEquals(resultWithoutGuessed.inferredTypes, resultWithGuessed.inferredTypes)
assertEquals(resultWithGuessed, resultWithGuessedTwice)

println("=".repeat(42))
println("Inferred types WITHOUT guesser: ")
Expand Down
8 changes: 8 additions & 0 deletions usvm-ts/src/main/kotlin/org/usvm/machine/TsContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ import io.ksmt.utils.asExpr
import org.jacodb.ets.model.EtsAliasType
import org.jacodb.ets.model.EtsAnyType
import org.jacodb.ets.model.EtsArrayType
import org.jacodb.ets.model.EtsBooleanLiteralType
import org.jacodb.ets.model.EtsBooleanType
import org.jacodb.ets.model.EtsEnumValueType
import org.jacodb.ets.model.EtsGenericType
import org.jacodb.ets.model.EtsLocal
import org.jacodb.ets.model.EtsLexicalEnvType
import org.jacodb.ets.model.EtsMethod
import org.jacodb.ets.model.EtsNullType
import org.jacodb.ets.model.EtsNumberLiteralType
import org.jacodb.ets.model.EtsNumberType
import org.jacodb.ets.model.EtsParameterRef
import org.jacodb.ets.model.EtsRefType
import org.jacodb.ets.model.EtsScene
import org.jacodb.ets.model.EtsStringLiteralType
import org.jacodb.ets.model.EtsStringType
import org.jacodb.ets.model.EtsThis
import org.jacodb.ets.model.EtsType
Expand Down Expand Up @@ -134,13 +138,17 @@ class TsContext(
}

fun typeToSort(type: EtsType): USort = when (type) {
is EtsBooleanLiteralType -> boolSort
is EtsBooleanType -> boolSort
is EtsNumberLiteralType -> fp64Sort
is EtsNumberType -> fp64Sort
is EtsStringLiteralType -> addressSort
is EtsStringType -> addressSort
is EtsNullType -> addressSort
is EtsUndefinedType -> addressSort
is EtsUnionType -> unresolvedSort
is EtsRefType -> addressSort
is EtsLexicalEnvType -> addressSort
is EtsAnyType -> unresolvedSort
is EtsUnknownType -> unresolvedSort
is EtsAliasType -> typeToSort(type.originalType)
Expand Down
30 changes: 19 additions & 11 deletions usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallApproximations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -485,18 +485,20 @@ private fun TsExprResolver.handleArrayFill(
}

scope.calcOnState {
val descriptor = arrayDescriptorOf(arrayType)

// Calculate the length of the range to fill
val fillLength = mkBvSubExpr(endBv, startBv)

// TODO: check that `fillLength` is less than `ARRAY_FILL_MAX_SIZE`

// Allocate a temporary array to hold the filled values
val tempArray = memory.allocConcrete(arrayType)
val tempArray = memory.allocConcrete(descriptor)

// Fill the temporary array with the specified `value`
memory.initializeArray(
tempArray,
arrayType,
descriptor,
elementSort,
sizeSort,
(0 until ARRAY_FILL_MAX_SIZE).asSequence().map { value.asExpr(elementSort) }
Expand All @@ -506,7 +508,7 @@ private fun TsExprResolver.handleArrayFill(
memory.memcpy(
srcRef = tempArray,
dstRef = array,
type = arrayType,
type = descriptor,
elementSort = elementSort,
fromSrc = mkBv(0),
fromDst = startBv,
Expand Down Expand Up @@ -777,17 +779,19 @@ private fun TsExprResolver.handleArraySlice(
}

scope.calcOnState {
val descriptor = arrayDescriptorOf(arrayType)

// Calculate the new length of the sliced array
val newLength = mkBvSubExpr(endBv, startBv)

// Allocate a new array for the slice
val slicedArray = memory.allocConcrete(arrayType)
val slicedArray = memory.allocConcrete(descriptor)

// Copy the specified range from the original array to the new array
memory.memcpy(
srcRef = array,
dstRef = slicedArray,
type = arrayType,
type = descriptor,
elementSort = elementSort,
fromSrc = startBv,
fromDst = mkBv(0),
Expand Down Expand Up @@ -830,8 +834,10 @@ private fun TsExprResolver.handleArrayConcat(
val args = expr.args.map { resolve(it) ?: return null }

scope.calcOnState {
val descriptor = arrayDescriptorOf(arrayType)

// Allocate a new array for the concatenated result
val resultArray = memory.allocConcrete(arrayType)
val resultArray = memory.allocConcrete(descriptor)

// Read the length of the original array
val originalLengthLValue = mkArrayLengthLValue(array, arrayType)
Expand All @@ -841,7 +847,7 @@ private fun TsExprResolver.handleArrayConcat(
memory.memcpy(
srcRef = array,
dstRef = resultArray,
type = arrayType,
type = descriptor,
elementSort = elementSort,
fromSrc = mkBv(0),
fromDst = mkBv(0),
Expand All @@ -863,7 +869,7 @@ private fun TsExprResolver.handleArrayConcat(
memory.memcpy(
srcRef = arg.asExpr(addressSort),
dstRef = resultArray,
type = arrayType,
type = descriptor,
elementSort = elementSort,
fromSrc = mkBv(0),
fromDst = totalLength,
Expand Down Expand Up @@ -1037,8 +1043,10 @@ private fun TsExprResolver.handleArrayReverse(
}

scope.calcOnState {
val descriptor = arrayDescriptorOf(arrayType)

// Allocate a new array to represent the reversed result
val reversedArray = memory.allocConcrete(arrayType)
val reversedArray = memory.allocConcrete(descriptor)

// Read the length of the original array
val lengthLValue = mkArrayLengthLValue(array, arrayType)
Expand All @@ -1047,7 +1055,7 @@ private fun TsExprResolver.handleArrayReverse(
// Initialize the reversed array with symbolic elements
memory.initializeArray(
reversedArray,
arrayType,
descriptor,
elementSort,
sizeSort,
(0 until ARRAY_REVERSE_MAX_SIZE).asSequence().map { index ->
Expand All @@ -1073,7 +1081,7 @@ private fun TsExprResolver.handleArrayReverse(
memory.memcpy(
srcRef = reversedArray,
dstRef = array,
type = arrayType,
type = descriptor,
elementSort = elementSort,
fromSrc = mkBv(0),
fromDst = mkBv(0),
Expand Down
10 changes: 8 additions & 2 deletions usvm-ts/src/main/kotlin/org/usvm/machine/expr/CallStatic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.usvm.machine.state.TsMethodResult
import org.usvm.machine.state.lastStmt
import org.usvm.machine.state.newStmt
import org.usvm.util.TsResolutionResult
import org.usvm.util.canonicalizeExecutableOverloads

private val logger = KotlinLogging.logger {}

Expand Down Expand Up @@ -70,21 +71,26 @@ private fun TsExprResolver.resolveStaticMethod(
if (method.enclosingClass.name != UNKNOWN_CLASS_NAME) {
val classes = hierarchy.classesForType(EtsClassType(method.enclosingClass))
if (classes.size > 1) {
val methods = classes.map { it.methods.single { it.name == method.name } }
val methods = classes
.flatMap { clazz -> clazz.methods.filter { it.name == method.name } }
.canonicalizeExecutableOverloads()
return TsResolutionResult.create(methods)
}

if (classes.isEmpty()) return TsResolutionResult.Empty

val clazz = classes.single()
val methods = clazz.methods.filter { it.name == method.name }
val methods = clazz.methods
.filter { it.name == method.name }
.canonicalizeExecutableOverloads()
return TsResolutionResult.create(methods)
}

// Unknown signature:
val methods = ctx.scene.projectAndSdkClasses
.flatMap { it.methods }
.filter { it.name == method.name }
.canonicalizeExecutableOverloads()

return TsResolutionResult.create(methods)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.usvm.machine.expr

import org.jacodb.ets.model.EtsStaticCallExpr
import org.jacodb.ets.model.EtsValue
import org.usvm.UExpr
import org.usvm.machine.expr.TsExprApproximationResult.Companion.from

Expand All @@ -14,7 +15,7 @@ internal fun TsExprResolver.tryApproximateStaticCall(

// Handle `Number(...)` calls
if (expr.callee.name == "Number") {
return from(handleNumberConverter(expr))
return from(handleNumberConverter(expr.args))
}

// Handle `Boolean(...)` calls
Expand All @@ -33,11 +34,14 @@ private fun TsExprResolver.handleR(): UExpr<*> = with(ctx) {
mockSymbol
}

private fun TsExprResolver.handleNumberConverter(expr: EtsStaticCallExpr): UExpr<*>? = with(ctx) {
check(expr.args.size == 1) {
"Number() should have exactly one argument, but got ${expr.args.size}"
internal fun TsExprResolver.handleNumberConverter(args: List<EtsValue>): UExpr<*>? = with(ctx) {
if (args.isEmpty()) {
return mkFp64(0.0)
}
Comment thread
CaelmBleidd marked this conversation as resolved.
val arg = resolve(expr.args.single()) ?: return null
check(args.size == 1) {
"Number() should have exactly one argument, but got ${args.size}"
}
val arg = resolve(args.single()) ?: return null
return mkNumericExpr(arg, scope)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.usvm.machine.expr

import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import kotlin.math.absoluteValue

private const val MAX_SIGNIFICANT_DIGITS = 17
private const val MAX_PLAIN_DECIMAL_POINT = 21
private const val MIN_PLAIN_DECIMAL_POINT = -5
private const val ROUNDING_NEIGHBORHOOD = 2

/** Formats a concrete IEEE-754 value according to ECMAScript Number::toString. */
internal fun Double.toEcmaScriptString(): String = when {
isNaN() -> {
"NaN"
}

this == Double.POSITIVE_INFINITY -> {
"Infinity"
}

this == Double.NEGATIVE_INFINITY -> {
"-Infinity"
}

this == 0.0 -> {
"0"
}

else -> {
val negative = this < 0.0
val decimal = absoluteValue.shortestRoundTripDecimal().stripTrailingZeros()
val digits = decimal.unscaledValue().abs().toString()
val decimalPoint = digits.length - decimal.scale()
val unsigned = when {
decimalPoint in 1..MAX_PLAIN_DECIMAL_POINT -> {
if (decimalPoint >= digits.length) {
digits + "0".repeat(decimalPoint - digits.length)
} else {
digits.substring(0, decimalPoint) + "." + digits.substring(decimalPoint)
}
}

decimalPoint in MIN_PLAIN_DECIMAL_POINT..0 -> {
"0." + "0".repeat(-decimalPoint) + digits
}

else -> {
val mantissa = if (digits.length == 1) {
digits
} else {
digits.substring(0, 1) + "." + digits.substring(1)
}
val exponent = decimalPoint - 1
mantissa + "e" + (if (exponent >= 0) "+" else "") + exponent
}
}
if (negative) "-$unsigned" else unsigned
}
}

private fun Double.shortestRoundTripDecimal(): BigDecimal {
// This constructor intentionally retains the exact binary value. At the first
// precision that round-trips, ECMAScript selects the closest decimal (ties to even).
val exact = BigDecimal(this)
for (precision in 1..MAX_SIGNIFICANT_DIGITS) {
val rounded = exact.round(MathContext(precision, RoundingMode.HALF_EVEN))
val unit = rounded.ulp()
val candidates = (-ROUNDING_NEIGHBORHOOD..ROUNDING_NEIGHBORHOOD)
.asSequence()
.map { offset -> rounded + unit * offset.toBigDecimal() }
.filter { candidate -> candidate.signum() > 0 && candidate.toDouble() == this }
.toList()
if (candidates.isNotEmpty()) {
return candidates.minWith(
compareBy<BigDecimal> { candidate -> candidate.subtract(exact).abs() }
.thenBy { candidate -> candidate.unscaledValue().abs().and(BigInteger.ONE).toInt() }
.thenBy { candidate -> candidate },
)
}
}
error("Could not format finite double: $this")
}
Loading
Loading